@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.
- package/README.md +22 -2
- package/package.json +6 -6
- package/src/audit/constraintAudit.spec.ts +180 -0
- package/src/audit/constraintAudit.ts +116 -37
- package/src/audit/themeMatrix.ts +4 -1
- 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/createThemes.ts +43 -6
- 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 +99 -25
- package/src/theme/semanticEntryText.spec.ts +85 -0
- 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/audit/themeMatrix.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/createThemes.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 +35 -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
|
@@ -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
|
|
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 =
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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;
|
package/src/theme/index.ts
CHANGED
|
@@ -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" }
|
|
218
|
-
expect(capture.result.knobProps.elevation).
|
|
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" }
|
|
227
|
-
expect(capture.result.knobProps.elevation).
|
|
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"
|
|
291
|
-
expect(capture.result.knobProps.elevation).
|
|
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.
|
package/src/theme/radiusClass.ts
CHANGED
|
@@ -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" →
|
|
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,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
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
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:
|
|
262
|
-
: { none: undefined, small:
|
|
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
|
-
|
|
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,
|
|
359
|
+
const elevationKeyAlias: Record<string, ElevationKey> = {
|
|
300
360
|
$1: "$1",
|
|
301
361
|
$2: "$2",
|
|
302
|
-
$3: "$
|
|
362
|
+
$3: "$3",
|
|
303
363
|
$4: "$4",
|
|
304
|
-
$5: "$
|
|
364
|
+
$5: "$5",
|
|
305
365
|
"20": "$1",
|
|
306
366
|
"28": "$2",
|
|
307
|
-
"36": "$
|
|
367
|
+
"36": "$3",
|
|
308
368
|
"44": "$4",
|
|
309
|
-
"52": "$
|
|
369
|
+
"52": "$5",
|
|
310
370
|
};
|
|
311
371
|
|
|
312
|
-
const webShadowLight: Record<
|
|
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
|
-
|
|
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):
|
|
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
|
+
});
|
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
|
|