@multiplatform.one/theme 7.11.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.
- package/README.md +22 -2
- package/package.json +6 -6
- package/src/audit/constraintAudit.spec.ts +102 -0
- package/src/audit/constraintAudit.ts +78 -33
- package/src/figma/figmaTokens.spec.ts +1 -2
- package/src/numbers.spec.ts +27 -1
- package/src/numbers.ts +40 -0
- package/src/theme/Surface.spec.tsx +9 -4
- package/src/theme/createDefaultThemeConfig.ts +8 -1
- package/src/theme/index.ts +3 -0
- package/src/theme/intent.spec.tsx +9 -7
- package/src/theme/layoutTokensHooks.spec.tsx +3 -2
- package/src/theme/radiusClass.ts +18 -0
- package/src/theme/resolveKnobs.spec.ts +73 -7
- package/src/theme/resolveKnobs.ts +84 -25
- package/src/theme/sizeRecipes.ts +8 -5
- package/src/theme/subsetThemes.spec.ts +102 -0
- package/src/theme/subsetThemes.ts +56 -0
- package/src/theme/theme.tsx +15 -5
- package/src/theme/transitionProps.native.ts +15 -0
- package/src/theme/transitionProps.spec.ts +14 -0
- package/src/theme/transitionProps.ts +16 -0
- package/src/theme/useResolvedKnobs.hydration.spec.tsx +141 -0
- package/src/theme/useResolvedKnobs.ts +28 -22
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +15 -1
- package/src/theme/useTouchSurface.ts +8 -0
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/numbers.d.ts +12 -0
- package/types/numbers.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +7 -0
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/index.d.ts +3 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/radiusClass.d.ts +14 -0
- package/types/theme/radiusClass.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +23 -1
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +8 -5
- package/types/theme/sizeRecipes.d.ts.map +1 -1
- package/types/theme/subsetThemes.d.ts +39 -0
- package/types/theme/subsetThemes.d.ts.map +1 -0
- package/types/theme/theme.d.ts.map +1 -1
- package/types/theme/transitionProps.d.ts +14 -0
- package/types/theme/transitionProps.d.ts.map +1 -0
- package/types/theme/transitionProps.native.d.ts +13 -0
- package/types/theme/transitionProps.native.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- package/types/theme/useTouchSurface.d.ts +3 -0
- 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" →
|
|
228
|
-
|
|
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.
|
|
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).
|
|
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: "
|
|
355
|
-
expect(elevation.focusKnobProps?.elevation).toBe("$
|
|
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).
|
|
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
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
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:
|
|
262
|
-
: { none: undefined, small:
|
|
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
|
-
|
|
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,
|
|
344
|
+
const elevationKeyAlias: Record<string, ElevationKey> = {
|
|
300
345
|
$1: "$1",
|
|
301
346
|
$2: "$2",
|
|
302
|
-
$3: "$
|
|
347
|
+
$3: "$3",
|
|
303
348
|
$4: "$4",
|
|
304
|
-
$5: "$
|
|
349
|
+
$5: "$5",
|
|
305
350
|
"20": "$1",
|
|
306
351
|
"28": "$2",
|
|
307
|
-
"36": "$
|
|
352
|
+
"36": "$3",
|
|
308
353
|
"44": "$4",
|
|
309
|
-
"52": "$
|
|
354
|
+
"52": "$5",
|
|
310
355
|
};
|
|
311
356
|
|
|
312
|
-
const webShadowLight: Record<
|
|
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
|
-
|
|
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):
|
|
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];
|
package/src/theme/sizeRecipes.ts
CHANGED
|
@@ -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
|
|
225
|
-
* statement. Pilot: Button/, InputParts/,
|
|
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
|
+
}
|
package/src/theme/theme.tsx
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 (
|
|
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
|
+
}
|