@multiplatform.one/theme 7.10.0 → 7.15.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 (49) hide show
  1. package/README.md +22 -2
  2. package/package.json +8 -8
  3. package/src/audit/constraintAudit.spec.ts +102 -0
  4. package/src/audit/constraintAudit.ts +78 -33
  5. package/src/figma/figmaTokens.spec.ts +1 -2
  6. package/src/numbers.spec.ts +27 -1
  7. package/src/numbers.ts +40 -0
  8. package/src/theme/Surface.spec.tsx +9 -4
  9. package/src/theme/createDefaultThemeConfig.ts +8 -1
  10. package/src/theme/index.ts +3 -0
  11. package/src/theme/intent.spec.tsx +9 -7
  12. package/src/theme/layoutTokensHooks.spec.tsx +3 -2
  13. package/src/theme/radiusClass.ts +18 -0
  14. package/src/theme/resolveKnobs.spec.ts +73 -7
  15. package/src/theme/resolveKnobs.ts +84 -25
  16. package/src/theme/sizeRecipes.ts +8 -5
  17. package/src/theme/subsetThemes.spec.ts +102 -0
  18. package/src/theme/subsetThemes.ts +56 -0
  19. package/src/theme/theme.tsx +15 -5
  20. package/src/theme/transitionProps.native.ts +15 -0
  21. package/src/theme/transitionProps.spec.ts +14 -0
  22. package/src/theme/transitionProps.ts +16 -0
  23. package/src/theme/useResolvedKnobs.hydration.spec.tsx +141 -0
  24. package/src/theme/useResolvedKnobs.ts +28 -22
  25. package/src/theme/useResolvedKnobsBehavior.spec.tsx +15 -1
  26. package/src/theme/useTouchSurface.ts +8 -0
  27. package/types/audit/constraintAudit.d.ts.map +1 -1
  28. package/types/numbers.d.ts +12 -0
  29. package/types/numbers.d.ts.map +1 -1
  30. package/types/theme/createDefaultThemeConfig.d.ts +7 -0
  31. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  32. package/types/theme/index.d.ts +3 -0
  33. package/types/theme/index.d.ts.map +1 -1
  34. package/types/theme/radiusClass.d.ts +14 -0
  35. package/types/theme/radiusClass.d.ts.map +1 -1
  36. package/types/theme/resolveKnobs.d.ts +23 -1
  37. package/types/theme/resolveKnobs.d.ts.map +1 -1
  38. package/types/theme/sizeRecipes.d.ts +8 -5
  39. package/types/theme/sizeRecipes.d.ts.map +1 -1
  40. package/types/theme/subsetThemes.d.ts +39 -0
  41. package/types/theme/subsetThemes.d.ts.map +1 -0
  42. package/types/theme/theme.d.ts.map +1 -1
  43. package/types/theme/transitionProps.d.ts +14 -0
  44. package/types/theme/transitionProps.d.ts.map +1 -0
  45. package/types/theme/transitionProps.native.d.ts +13 -0
  46. package/types/theme/transitionProps.native.d.ts.map +1 -0
  47. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  48. package/types/theme/useTouchSurface.d.ts +3 -0
  49. package/types/theme/useTouchSurface.d.ts.map +1 -0
@@ -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,43 @@ export function capContainerRadius(
174
174
  return radiusPx > paddingPx ? paddingPx : borderRadiusMap[borderRadius];
175
175
  }
176
176
 
177
+ /** Containers the constraint audit measures against the cap (DG-RAD-04). */
178
+ export type CappedContainer = "PageSection" | "TextArea";
179
+
180
+ export interface ContainerCapDeclaration {
181
+ "data-constraint-container": CappedContainer;
182
+ "data-radius-knob": Knobs["borderRadius"];
183
+ "data-space-knob": Knobs["space"];
184
+ }
185
+
186
+ /**
187
+ * Declare a container that spreads `containerRadius`, with the knob stops it
188
+ * resolved, so the constraint audit checks its painted corners against
189
+ * `capContainerRadius` for those stops. The cap is a padding px value, not a
190
+ * radius token, so an undeclared capped container reads off-scale at `full`.
191
+ * Returns nothing when `radiusToken` is not a `borderRadiusMap` token or
192
+ * `space` is not a space stop: a part whose radius did not come from the knob
193
+ * makes no claim.
194
+ *
195
+ * @example
196
+ * <YStack {...knobProps.containerRadius} {...containerCapProps("PageSection", knobProps.borderRadius.borderRadius, knobProps.space)} />
197
+ */
198
+ export function containerCapProps(
199
+ container: CappedContainer,
200
+ radiusToken: unknown,
201
+ space: string,
202
+ ): ContainerCapDeclaration | undefined {
203
+ const radiusKnob = (Object.keys(borderRadiusMap) as Knobs["borderRadius"][]).find(
204
+ (stop) => borderRadiusMap[stop] === radiusToken,
205
+ );
206
+ if (!radiusKnob || !Object.hasOwn(panelPaddingPxMap, space)) return undefined;
207
+ return {
208
+ "data-constraint-container": container,
209
+ "data-radius-knob": radiusKnob,
210
+ "data-space-knob": space as Knobs["space"],
211
+ };
212
+ }
213
+
177
214
  const gapMap = {
178
215
  small: "$2",
179
216
  medium: "$4",
@@ -252,14 +289,16 @@ const fontWeightMap = {
252
289
  // tokens.size values Tamagui resolves those tokens to ($1=20, $2=28, $4=44),
253
290
  // so Tamagui consumers render identical shadows either way.
254
291
  //
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
292
+ // Controls are flat at the default `small` stop (MPO-96: the reference
293
+ // measures no resting shadow on a button or a card), so `small` paints
294
+ // nothing on the control map and only medium / large lift a control.
295
+ // Overlay elevation is a DISTINCT ladder one step above the control map, so
296
+ // a dialog over a scrim and a Button on the page never read as the same
258
297
  // distance off the surface (DG-OVL-01 / E-OVERLAY). elevatedSurface binds
259
298
  // the overlay token — not the control one.
260
299
  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 };
300
+ ? { none: undefined, small: undefined, medium: "$2", large: "$4" }
301
+ : { none: undefined, small: undefined, medium: 28, large: 44 };
263
302
 
264
303
  export const overlayElevationMap: Record<string, string | number | undefined> = isWeb
265
304
  ? { none: undefined, small: "$2", medium: "$3", large: "$5" }
@@ -285,43 +324,70 @@ const elevationTintInput: Record<Elevation, string> = {
285
324
  large: "$color5",
286
325
  };
287
326
 
327
+ type ElevationKey = "$1" | "$2" | "$3" | "$4" | "$5";
328
+
288
329
  // Axiom 14 light source: y-offset:blur = 0.5. Metrics match the maps
289
- // InputParts / getElevationWrapperProps already shipped.
330
+ // InputParts / getElevationWrapperProps already shipped. `$3` and `$5` are
331
+ // the overlay-only stops (MPO-99): the reference §8 popper and dialog
332
+ // shadows. Native paints one shadow, so it takes the first web layer.
290
333
  const elevationShadowMetrics: Record<
291
- "$1" | "$2" | "$4",
334
+ ElevationKey,
292
335
  { offsetY: number; radius: number; android: number }
293
336
  > = {
294
337
  $1: { offsetY: 1, radius: 2, android: 2 },
295
338
  $2: { offsetY: 3, radius: 6, android: 4 },
339
+ $3: { offsetY: 4, radius: 8, android: 6 },
296
340
  $4: { offsetY: 10, radius: 20, android: 8 },
341
+ $5: { offsetY: 12, radius: 24, android: 12 },
297
342
  };
298
343
 
299
- const elevationKeyAlias: Record<string, "$1" | "$2" | "$4"> = {
344
+ const elevationKeyAlias: Record<string, ElevationKey> = {
300
345
  $1: "$1",
301
346
  $2: "$2",
302
- $3: "$2",
347
+ $3: "$3",
303
348
  $4: "$4",
304
- $5: "$4",
349
+ $5: "$5",
305
350
  "20": "$1",
306
351
  "28": "$2",
307
- "36": "$2",
352
+ "36": "$3",
308
353
  "44": "$4",
309
- "52": "$4",
354
+ "52": "$5",
310
355
  };
311
356
 
312
- const webShadowLight: Record<"$1" | "$2" | "$4", string> = {
357
+ const webShadowLight: Record<ElevationKey, string> = {
313
358
  $1: "0 1px 2px rgba(0,0,0,0.12), 0 0 1px rgba(0,0,0,0.08)",
314
359
  $2: "0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.10)",
360
+ $3: "0 4px 8px rgba(0,0,0,0.10), 0 12px 32px rgba(0,0,0,0.08)",
315
361
  $4: "0 10px 20px rgba(0,0,0,0.15), 0 3px 6px rgba(0,0,0,0.10)",
362
+ $5: "0 12px 24px rgba(0,0,0,0.12)",
316
363
  };
317
364
 
318
- const webShadowDark: Record<"$1" | "$2" | "$4", string> = {
365
+ // LC-85: dark keeps each stop's geometry and inverts the hue.
366
+ const webShadowDark: Record<ElevationKey, string> = {
319
367
  $1: "0 1px 2px rgba(255,255,255,0.08), 0 0 1px rgba(255,255,255,0.06)",
320
368
  $2: "0 3px 6px rgba(255,255,255,0.10), 0 2px 4px rgba(255,255,255,0.06)",
369
+ $3: "0 4px 8px rgba(255,255,255,0.10), 0 12px 32px rgba(255,255,255,0.08)",
321
370
  $4: "0 10px 20px rgba(255,255,255,0.12), 0 3px 6px rgba(255,255,255,0.08)",
371
+ $5: "0 12px 24px rgba(255,255,255,0.12)",
372
+ };
373
+
374
+ const shadowOpacityLight: Record<ElevationKey, number> = {
375
+ $1: 0.12,
376
+ $2: 0.15,
377
+ $3: 0.1,
378
+ $4: 0.15,
379
+ $5: 0.12,
380
+ };
381
+
382
+ const shadowOpacityDark: Record<ElevationKey, number> = {
383
+ $1: 0.08,
384
+ $2: 0.1,
385
+ $3: 0.1,
386
+ $4: 0.12,
387
+ $5: 0.12,
322
388
  };
323
389
 
324
- function elevationTokenKey(token: string | number | undefined): "$1" | "$2" | "$4" | undefined {
390
+ function elevationTokenKey(token: string | number | undefined): ElevationKey | undefined {
325
391
  if (token == null) return undefined;
326
392
  return elevationKeyAlias[String(token)];
327
393
  }
@@ -337,15 +403,7 @@ function elevationPaint(
337
403
  const paint: ElevationPaintProps = {
338
404
  shadowColor: dark ? "$color12" : "#000",
339
405
  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,
406
+ shadowOpacity: dark ? shadowOpacityDark[key] : shadowOpacityLight[key],
349
407
  shadowRadius: metrics.radius,
350
408
  };
351
409
  if (isWeb) {
@@ -489,6 +547,7 @@ export function resolveKnobs(
489
547
  knobs: Knobs,
490
548
  override?: KnobPropsOverride,
491
549
  scheme: ColorScheme = "light",
550
+ environment?: { touch?: boolean },
492
551
  ): ResolvedKnobs {
493
552
  const radiusToken = borderRadiusMap[knobs.borderRadius];
494
553
  const nestedRadiusToken = nestedRadiusMap[knobs.borderRadius];
@@ -496,7 +555,7 @@ export function resolveKnobs(
496
555
  const nestedPx = nestedControlPxMap[knobs.size];
497
556
  const nestedHitSlop = pressTargetHitSlop(nestedPx);
498
557
  const sizeRecipe = sizeRecipeForToken(sizeToken, {
499
- touch: isTouchable || isWebTouchable,
558
+ touch: environment?.touch ?? (isTouchable || isWebTouchable),
500
559
  family: knobs.density === "compact" ? "controlCompact" : "control",
501
560
  });
502
561
  const paddingToken = panelPaddingMap[knobs.space];
@@ -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
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `10` AC-6 (MPO-23): a narrow surface ships a narrow theme matrix. The filter
3
+ * is pinned twice, once over hand-named themes and once over the matrix the
4
+ * house builder really emits, so a builder rename that stops a component
5
+ * sub-theme matching shows up here rather than as an untinted control.
6
+ */
7
+
8
+ import { describe, expect, it, vi } from "vitest";
9
+
10
+ vi.mock("tamagui", () => ({
11
+ createTamagui: vi.fn((config: Record<string, unknown>) => config),
12
+ }));
13
+ vi.mock("./animations/index", () => ({ animations: {} }));
14
+
15
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
16
+ import { createThemesBuilder } from "./createThemes";
17
+ import { defaultAccentTheme } from "./defaults/accent";
18
+ import { defaultBaseTheme } from "./defaults/base";
19
+ import { defaultBuilderOptions } from "./defaults/builderOptions";
20
+ import { type ThemeSubset, subsetThemes } from "./subsetThemes";
21
+
22
+ const narrow: ThemeSubset = {
23
+ components: ["Button", "Input"],
24
+ tints: ["accent", "active", "error"],
25
+ };
26
+
27
+ describe("subsetThemes", () => {
28
+ const names = [
29
+ "light",
30
+ "dark",
31
+ "light_accent",
32
+ "dark_accent_Button",
33
+ "light_Button",
34
+ "light_Input",
35
+ "light_active_Button",
36
+ "light_error",
37
+ "light_blue",
38
+ "dark_blue_Button",
39
+ "light_Tooltip",
40
+ "light_accent_Tooltip",
41
+ ];
42
+ const themes = Object.fromEntries(names.map((name) => [name, { name }]));
43
+
44
+ it("keeps the bare schemes and every listed tint x component, and nothing else", () => {
45
+ expect(Object.keys(subsetThemes(themes, narrow))).toEqual([
46
+ "light",
47
+ "dark",
48
+ "light_accent",
49
+ "dark_accent_Button",
50
+ "light_Button",
51
+ "light_Input",
52
+ "light_active_Button",
53
+ "light_error",
54
+ ]);
55
+ });
56
+
57
+ it("never rewrites a theme it keeps: filtering is not redefinition", () => {
58
+ const kept = subsetThemes(themes, narrow);
59
+ for (const [name, theme] of Object.entries(kept)) expect(theme).toBe(themes[name]);
60
+ });
61
+
62
+ it("keeps only the schemes when the subset is empty", () => {
63
+ expect(Object.keys(subsetThemes(themes, { components: [], tints: [] }))).toEqual([
64
+ "light",
65
+ "dark",
66
+ ]);
67
+ });
68
+ });
69
+
70
+ describe("subsetThemes over the house matrix", () => {
71
+ const built = createThemesBuilder(
72
+ defaultBaseTheme,
73
+ defaultAccentTheme,
74
+ defaultBuilderOptions,
75
+ ).themes() as Record<string, Record<string, unknown>>;
76
+
77
+ it("drops the decorative hues and keeps the semantic and state children", () => {
78
+ const kept = subsetThemes(built, {
79
+ components: ["Button"],
80
+ tints: ["accent", "active", "alt1", "alt2", "error", "success", "warning"],
81
+ });
82
+ expect(Object.keys(kept).length).toBeLessThan(Object.keys(built).length / 4);
83
+ for (const name of ["light", "dark", "light_accent", "light_Button", "dark_error_Button"]) {
84
+ expect(kept).toHaveProperty(name);
85
+ }
86
+ for (const name of ["light_blue", "dark_red", "light_Tooltip", "light_accent_Input"]) {
87
+ expect(built).toHaveProperty(name);
88
+ expect(kept).not.toHaveProperty(name);
89
+ }
90
+ });
91
+
92
+ it("is what createDefaultThemeConfig({ subset }) ships, and omitting it ships everything", () => {
93
+ const full = createDefaultThemeConfig().tamagui.themes as Record<string, unknown>;
94
+ const subset = createDefaultThemeConfig({ subset: narrow }).tamagui.themes as Record<
95
+ string,
96
+ unknown
97
+ >;
98
+ expect(Object.keys(full)).toEqual(Object.keys(built));
99
+ expect(Object.keys(subset)).toEqual(Object.keys(subsetThemes(built, narrow)));
100
+ expect(subset.light).toEqual(full.light);
101
+ });
102
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The themes one surface can reach (`10` AC-6, MPO-23).
3
+ *
4
+ * Themes are emitted into the served document as CSS, so a surface pays for
5
+ * every theme it will never enter. shc measured it on the live storefront:
6
+ * 721,818 of 1,232,058 decompressed bytes were the theme sheet, 921 classes of
7
+ * which the rendered routes asked for seven, and building the same house
8
+ * config over a named subset cut the sheet from 697,788 to 186,158 bytes
9
+ * (`shc/packages/themes/storefront.ts`). This is that subset as a pure
10
+ * function over the built matrix, so every app can do it the same way.
11
+ *
12
+ * It filters WHICH themes exist, never what a theme contains: `$color5` means
13
+ * the same thing in every theme that survives. A subset is a loaded gun, all
14
+ * the same. A component whose sub-theme was dropped does not crash, it renders
15
+ * in its parent's colours, so list everything the surface renders.
16
+ */
17
+ export interface ThemeSubset {
18
+ /**
19
+ * Component sub-themes the surface renders, by Tamagui componentName
20
+ * (`Button`, `Input`, `ListItem`, `SelectTrigger`, …). Tamagui enters one
21
+ * by rendering the component, so this is "which components exist on this
22
+ * surface", not a style choice.
23
+ */
24
+ components: readonly string[];
25
+ /**
26
+ * Named themes the surface can enter beside the bare `light`/`dark`
27
+ * schemes: `accent`, the semantic children (`error`, `success`,
28
+ * `warning`), Tamagui's state children (`active`, `alt1`, `alt2`), and any
29
+ * decorative hue a `theme="…"` or `<Theme name="…">` on the surface names.
30
+ */
31
+ tints: readonly string[];
32
+ }
33
+
34
+ const SCHEMES = new Set(["light", "dark"]);
35
+
36
+ /**
37
+ * Keep a theme when its component suffix (if any) is in `components` and
38
+ * every other non-scheme segment of its name is in `tints`. Names are
39
+ * `[light|dark_]<tint>*[_Component]`, so `light` and `dark` always survive.
40
+ */
41
+ export function subsetThemes<T>(themes: Record<string, T>, subset: ThemeSubset): Record<string, T> {
42
+ const components = new Set(subset.components);
43
+ const tints = new Set(subset.tints);
44
+ const kept: Record<string, T> = {};
45
+ for (const [name, theme] of Object.entries(themes)) {
46
+ const segments = name.split("_");
47
+ const last = segments[segments.length - 1] ?? "";
48
+ if (/^[A-Z]/.test(last)) {
49
+ segments.pop();
50
+ if (!components.has(last)) continue;
51
+ }
52
+ if (segments.some((segment) => !SCHEMES.has(segment) && !tints.has(segment))) continue;
53
+ kept[name] = theme;
54
+ }
55
+ return kept;
56
+ }
@@ -1,7 +1,12 @@
1
1
  import { ToastProvider } from "@tamagui/toast";
2
2
  import { useContext, useEffect, useInsertionEffect, useMemo, useRef } from "react";
3
3
  import { useCookies } from "react-cookie";
4
- import { type CreateTamaguiProps, TamaguiProvider, type TamaguiProviderProps } from "tamagui";
4
+ import {
5
+ type CreateTamaguiProps,
6
+ TamaguiProvider,
7
+ type TamaguiProviderProps,
8
+ useDidFinishSSR,
9
+ } from "tamagui";
5
10
  import type { PropsWithChildren } from "react";
6
11
  import { animationDurations } from "./animations/css";
7
12
  import { getCookieWatchList, readOverridesCookie, readPresetCookie } from "./cookies";
@@ -67,12 +72,15 @@ export function ThemeProvider<T extends CreateTamaguiProps>({
67
72
  // - Storybook: decorator resolves "system"/"light"/"dark" with OS listener
68
73
  // ThemeProvider simply applies whatever the caller provides.
69
74
  const activeTheme = systemTheme;
75
+ // A hydrating root renders a light placeholder to match SSR (MPO-278); the
76
+ // blocking scheme script owns <html> until hydration finishes.
77
+ const hydrated = useDidFinishSSR();
70
78
 
71
- const prevThemeRef = useRef(activeTheme);
79
+ const prevThemeRef = useRef<string | undefined>(undefined);
72
80
 
73
81
  // Set the initial theme class synchronously to avoid FOUC.
74
82
  useInsertionEffect(() => {
75
- if (typeof document === "undefined") return;
83
+ if (typeof document === "undefined" || !hydrated) return;
76
84
  const classList = document.documentElement.classList;
77
85
  const toAdd = `t_${activeTheme}`;
78
86
  if (!classList.contains(toAdd)) {
@@ -80,12 +88,14 @@ export function ThemeProvider<T extends CreateTamaguiProps>({
80
88
  classList.add(toAdd);
81
89
  }
82
90
  document.documentElement.style.colorScheme = activeTheme;
83
- }, [activeTheme]);
91
+ }, [activeTheme, hydrated]);
84
92
 
85
93
  // Animate subsequent scheme changes via View Transitions / class-gated fallback.
86
94
  useEffect(() => {
87
- if (prevThemeRef.current === activeTheme) return;
95
+ if (!hydrated) return;
96
+ const previous = prevThemeRef.current;
88
97
  prevThemeRef.current = activeTheme;
98
+ if (previous === undefined || previous === activeTheme) return;
89
99
 
90
100
  startThemeTransition(() => {
91
101
  const classList = document.documentElement.classList;
@@ -0,0 +1,15 @@
1
+ import type { TransitionProp } from "tamagui";
2
+
3
+ /**
4
+ * Native twin of `transitionProps.ts`.
5
+ *
6
+ * The React Native driver resolves an undefined `transition` to an empty
7
+ * config and animates with a default spring (LC-59), so keeping the key at
8
+ * animation `none` would put motion on every part that is meant to be still.
9
+ * Native keeps omitting it until the driver has an instant token to pass.
10
+ */
11
+ export function transitionProps(transition: TransitionProp | undefined): {
12
+ transition?: TransitionProp;
13
+ } {
14
+ return transition ? { transition } : {};
15
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transitionProps } from "./transitionProps";
3
+
4
+ describe("transitionProps (web)", () => {
5
+ it("keeps the transition key at animation none so a live knob flip adds no hooks (MPO-327)", () => {
6
+ const atNone = transitionProps(undefined);
7
+ expect("transition" in atNone).toBe(true);
8
+ expect(atNone.transition).toBeUndefined();
9
+ });
10
+
11
+ it("passes a knob token through unchanged", () => {
12
+ expect(transitionProps("bouncy")).toEqual({ transition: "bouncy" });
13
+ });
14
+ });
@@ -0,0 +1,16 @@
1
+ import type { TransitionProp } from "tamagui";
2
+
3
+ /**
4
+ * Spreadable `transition` for a part that rides the animation knob.
5
+ *
6
+ * Tamagui decides whether a component runs its animation hooks from
7
+ * `"transition" in props`, so a part that drops the key at animation `none`
8
+ * throws "Rendered more hooks than during the previous render" the moment the
9
+ * knob flips to any other stop (MPO-327). On web the key therefore stays, and
10
+ * an undefined value paints no CSS transition. The native twin differs.
11
+ */
12
+ export function transitionProps(transition: TransitionProp | undefined): {
13
+ transition?: TransitionProp;
14
+ } {
15
+ return { transition };
16
+ }