@multiplatform.one/theme 6.0.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/CHANGELOG.md +18 -0
- package/LICENSE +201 -0
- package/package.json +85 -0
- package/src/audit/baselines/home-login.json +171 -0
- package/src/audit/baselines/home-login.png +0 -0
- package/src/audit/baselines/home-pokemon.json +355 -0
- package/src/audit/baselines/home-pokemon.png +0 -0
- package/src/audit/baselines/home.json +194 -0
- package/src/audit/baselines/home.png +0 -0
- package/src/audit/baselines/pokemon.json +251 -0
- package/src/audit/baselines/pokemon.png +0 -0
- package/src/audit/constraintAudit.ts +874 -0
- package/src/audit/diffReport.spec.ts +191 -0
- package/src/audit/diffReport.ts +344 -0
- package/src/audit/index.ts +10 -0
- package/src/font.ts +49 -0
- package/src/fonts/createFontLoader.ts +15 -0
- package/src/fonts/createFontLoader.web.ts +9 -0
- package/src/fonts/index.ts +2 -0
- package/src/fonts/types.ts +13 -0
- package/src/index.ts +3 -0
- package/src/theme/Intent.tsx +30 -0
- package/src/theme/Preset.tsx +93 -0
- package/src/theme/PresetContext.ts +27 -0
- package/src/theme/Tint.tsx +44 -0
- package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +69 -0
- package/src/theme/animations/css.ts +25 -0
- package/src/theme/animations/index.ts +1 -0
- package/src/theme/animations/index.web.ts +1 -0
- package/src/theme/animations/reactNative.ts +54 -0
- package/src/theme/colorRules.spec.ts +195 -0
- package/src/theme/colorRules.ts +166 -0
- package/src/theme/cookies.spec.ts +185 -0
- package/src/theme/cookies.ts +209 -0
- package/src/theme/createDefaultThemeConfig.ts +86 -0
- package/src/theme/createThemes.ts +286 -0
- package/src/theme/devtools/ColorLineVisualizer.tsx +261 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +1657 -0
- package/src/theme/devtools/buildSnapshot.spec.ts +113 -0
- package/src/theme/devtools/buildSnapshot.ts +103 -0
- package/src/theme/devtools/knobIcons.tsx +217 -0
- package/src/theme/devtools/vizConstants.spec.ts +58 -0
- package/src/theme/devtools/vizConstants.ts +28 -0
- package/src/theme/index.ts +21 -0
- package/src/theme/intent.spec.tsx +276 -0
- package/src/theme/intents.ts +22 -0
- package/src/theme/knobs.ts +147 -0
- package/src/theme/pokemonScreen.spec.ts +109 -0
- package/src/theme/preset.spec.tsx +242 -0
- package/src/theme/preset.types.ts +22 -0
- package/src/theme/presets.spec.ts +252 -0
- package/src/theme/presets.ts +49 -0
- package/src/theme/recipes.ts +69 -0
- package/src/theme/resolveKnobs.spec.ts +571 -0
- package/src/theme/resolveKnobs.ts +226 -0
- package/src/theme/shared.spec.ts +84 -0
- package/src/theme/shared.tsx +131 -0
- package/src/theme/theme.native.tsx +65 -0
- package/src/theme/theme.tsx +190 -0
- package/src/theme/tint.spec.tsx +179 -0
- package/src/theme/useColorScale.native.ts +7 -0
- package/src/theme/useColorScale.ts +29 -0
- package/src/theme/useResolvedKnobs.ts +121 -0
- package/src/theme/useTheme.native.ts +49 -0
- package/src/theme/useTheme.ts +213 -0
- package/tsconfig.json +11 -0
- package/vitest.config.mjs +9 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { type ReactNode, createElement } from "react";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Mock Tamagui's <Theme> so we can inspect the chosen theme name
|
|
7
|
+
vi.mock("tamagui", () => ({
|
|
8
|
+
Theme: ({ name, children }: { name: string; children: ReactNode }) =>
|
|
9
|
+
createElement("div", { "data-tamagui-theme": name }, children),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import type { ThemeName } from "@tamagui/web";
|
|
13
|
+
import { Intent } from "./Intent";
|
|
14
|
+
import { PresetContext, type PresetContextValue } from "./PresetContext";
|
|
15
|
+
import type { Preset as PresetType } from "./preset.types";
|
|
16
|
+
import { defaultKnobs } from "./knobs";
|
|
17
|
+
import type { ResolvedKnobs } from "./recipes";
|
|
18
|
+
import { useResolvedKnobs, useIntentContext } from "./useResolvedKnobs";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function makePreset(intents: PresetType["intents"] = {}): PresetType {
|
|
25
|
+
return {
|
|
26
|
+
theme: "light" as ThemeName,
|
|
27
|
+
knobs: defaultKnobs,
|
|
28
|
+
intents,
|
|
29
|
+
tints: [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function wrapWithPreset(preset: PresetType, children: ReactNode): ReactNode {
|
|
34
|
+
const ctx: PresetContextValue = { preset };
|
|
35
|
+
return createElement(PresetContext.Provider, { value: ctx }, children);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderToHtml(node: ReactNode): string {
|
|
39
|
+
return renderToString(node as React.ReactElement);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Return an array of all `data-tamagui-theme` values in rendered HTML. */
|
|
43
|
+
function collectThemes(html: string): string[] {
|
|
44
|
+
return Array.from(html.matchAll(/data-tamagui-theme="([^"]+)"/g), (m) => m[1]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Create a probe component that captures resolved knobs from useResolvedKnobs. */
|
|
48
|
+
function createRecipesCapture(options?: Parameters<typeof useResolvedKnobs>[0]) {
|
|
49
|
+
let captured: ResolvedKnobs | undefined;
|
|
50
|
+
function Probe() {
|
|
51
|
+
captured = useResolvedKnobs(options);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
Probe,
|
|
56
|
+
get result(): ResolvedKnobs {
|
|
57
|
+
if (!captured) throw new Error("Probe was not rendered");
|
|
58
|
+
return captured;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Create a probe that captures the intent context value. */
|
|
64
|
+
function createIntentCapture() {
|
|
65
|
+
let captured: string | null | undefined;
|
|
66
|
+
function Probe() {
|
|
67
|
+
captured = useIntentContext();
|
|
68
|
+
return createElement("span", null, "probe");
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
Probe,
|
|
72
|
+
get result(): string | null {
|
|
73
|
+
if (captured === undefined) throw new Error("Probe was not rendered");
|
|
74
|
+
return captured;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Intent context tests
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe("Intent", () => {
|
|
84
|
+
it("wraps children in <Theme name={name}> by default", () => {
|
|
85
|
+
const html = renderToHtml(
|
|
86
|
+
wrapWithPreset(
|
|
87
|
+
makePreset(),
|
|
88
|
+
createElement(Intent, { name: "error" }, createElement("span", null, "hello")),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const themes = collectThemes(html);
|
|
92
|
+
expect(themes).toContain("error");
|
|
93
|
+
expect(html).toContain("hello");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses explicit theme prop over intent name for Tamagui <Theme>", () => {
|
|
97
|
+
const html = renderToHtml(
|
|
98
|
+
wrapWithPreset(
|
|
99
|
+
makePreset(),
|
|
100
|
+
createElement(
|
|
101
|
+
Intent,
|
|
102
|
+
{ name: "error", theme: "red" as ThemeName },
|
|
103
|
+
createElement("span", null, "hello"),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
const themes = collectThemes(html);
|
|
108
|
+
expect(themes).toContain("red");
|
|
109
|
+
expect(themes).not.toContain("error");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("provides intent name via IntentContext (useIntentContext)", () => {
|
|
113
|
+
const capture = createIntentCapture();
|
|
114
|
+
|
|
115
|
+
renderToHtml(
|
|
116
|
+
wrapWithPreset(
|
|
117
|
+
makePreset(),
|
|
118
|
+
createElement(Intent, { name: "error" }, createElement(capture.Probe)),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
expect(capture.result).toBe("error");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("nesting: innermost <Intent> wins for context", () => {
|
|
125
|
+
const capture = createIntentCapture();
|
|
126
|
+
|
|
127
|
+
renderToHtml(
|
|
128
|
+
wrapWithPreset(
|
|
129
|
+
makePreset(),
|
|
130
|
+
createElement(
|
|
131
|
+
Intent,
|
|
132
|
+
{ name: "error" },
|
|
133
|
+
createElement(Intent, { name: "success" }, createElement(capture.Probe)),
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
expect(capture.result).toBe("success");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("nesting: innermost <Intent> produces both Tamagui themes in DOM order", () => {
|
|
141
|
+
const html = renderToHtml(
|
|
142
|
+
wrapWithPreset(
|
|
143
|
+
makePreset(),
|
|
144
|
+
createElement(
|
|
145
|
+
Intent,
|
|
146
|
+
{ name: "error" },
|
|
147
|
+
createElement(Intent, { name: "success" }, createElement("span", null, "inner")),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
const themes = collectThemes(html);
|
|
152
|
+
expect(themes).toEqual(["error", "success"]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// useResolvedKnobs + Intent integration
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
describe("useResolvedKnobs with Intent", () => {
|
|
161
|
+
it("resolves default intent overrides from context", () => {
|
|
162
|
+
const capture = createRecipesCapture();
|
|
163
|
+
|
|
164
|
+
renderToHtml(
|
|
165
|
+
wrapWithPreset(
|
|
166
|
+
makePreset(),
|
|
167
|
+
createElement(Intent, { name: "error" }, createElement(capture.Probe)),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// "error" defaultIntent has textAccent: "high" → passed through
|
|
172
|
+
expect(capture.result.knobProps.textAccent).toBe("high");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("resolves intent via explicit option overriding context", () => {
|
|
176
|
+
const capture = createRecipesCapture({ intent: "warning" });
|
|
177
|
+
|
|
178
|
+
renderToHtml(
|
|
179
|
+
wrapWithPreset(
|
|
180
|
+
makePreset(),
|
|
181
|
+
createElement(Intent, { name: "error" }, createElement(capture.Probe)),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// "warning" has textAccent: "high" → passed through
|
|
186
|
+
expect(capture.result.knobProps.textAccent).toBe("high");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("resolves component-specific intent overrides (Button + error)", () => {
|
|
190
|
+
const capture = createRecipesCapture({ component: "Button" });
|
|
191
|
+
|
|
192
|
+
renderToHtml(
|
|
193
|
+
wrapWithPreset(
|
|
194
|
+
makePreset(),
|
|
195
|
+
createElement(Intent, { name: "error" }, createElement(capture.Probe)),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// "error" → Button: { elevation: "small" } → resolved to "$1"
|
|
200
|
+
expect(capture.result.knobProps.elevation).toBe("$1");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("resolves component-specific intent overrides via explicit option", () => {
|
|
204
|
+
const capture = createRecipesCapture({ intent: "error", component: "Button" });
|
|
205
|
+
|
|
206
|
+
renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
|
|
207
|
+
|
|
208
|
+
// "error" → Button: { elevation: "small" } → resolved to "$1"
|
|
209
|
+
expect(capture.result.knobProps.elevation).toBe("$1");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("preset intent overrides take precedence over defaultIntents", () => {
|
|
213
|
+
const presetWithIntents = makePreset({
|
|
214
|
+
error: {
|
|
215
|
+
textAccent: "low",
|
|
216
|
+
Button: { elevation: "large" },
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const capture = createRecipesCapture({ intent: "error", component: "Button" });
|
|
221
|
+
|
|
222
|
+
renderToHtml(wrapWithPreset(presetWithIntents, createElement(capture.Probe)));
|
|
223
|
+
|
|
224
|
+
// Preset says textAccent: "low" → passed through
|
|
225
|
+
expect(capture.result.knobProps.textAccent).toBe("low");
|
|
226
|
+
|
|
227
|
+
// Preset says Button.elevation: "large" → resolved to "$4"
|
|
228
|
+
expect(capture.result.knobProps.elevation).toBe("$4");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("falls back to defaultIntents when preset has no intent overrides", () => {
|
|
232
|
+
const capture = createRecipesCapture({ intent: "accent", component: "Button" });
|
|
233
|
+
|
|
234
|
+
renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
|
|
235
|
+
|
|
236
|
+
// Component override: Button.fillStyle = "filled" overrides the intent-level "outlined"
|
|
237
|
+
expect(capture.result.knobProps.outlined).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("accent intent without component uses outlined fill from defaultIntents", () => {
|
|
241
|
+
const capture = createRecipesCapture({ intent: "accent" });
|
|
242
|
+
|
|
243
|
+
renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
|
|
244
|
+
|
|
245
|
+
// accent defaultIntent: fillStyle: "outlined"
|
|
246
|
+
expect(capture.result.knobProps.outlined).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("nesting intents: innermost wins for recipe resolution", () => {
|
|
250
|
+
const capture = createRecipesCapture({ component: "Button" });
|
|
251
|
+
|
|
252
|
+
renderToHtml(
|
|
253
|
+
wrapWithPreset(
|
|
254
|
+
makePreset(),
|
|
255
|
+
createElement(
|
|
256
|
+
Intent,
|
|
257
|
+
{ name: "error" },
|
|
258
|
+
createElement(Intent, { name: "success" }, createElement(capture.Probe)),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// innermost is "success" → Button: { elevation: "none" } → undefined
|
|
264
|
+
expect(capture.result.knobProps.elevation).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("no intent context and no option → base knobs only", () => {
|
|
268
|
+
const capture = createRecipesCapture();
|
|
269
|
+
|
|
270
|
+
renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
|
|
271
|
+
|
|
272
|
+
// Default knobs: elevation "none" → resolved to undefined
|
|
273
|
+
expect(capture.result.knobProps.elevation).toBeUndefined();
|
|
274
|
+
expect(capture.result.knobProps.textAccent).toBe("high");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IntentOverride } from "./preset.types";
|
|
2
|
+
|
|
3
|
+
export const defaultIntents: Record<string, IntentOverride> = {
|
|
4
|
+
accent: {
|
|
5
|
+
fillStyle: "outlined",
|
|
6
|
+
textAccent: "high",
|
|
7
|
+
Button: { fillStyle: "filled" },
|
|
8
|
+
},
|
|
9
|
+
error: {
|
|
10
|
+
textAccent: "high",
|
|
11
|
+
Button: { elevation: "small" },
|
|
12
|
+
Input: { borderWidth: "medium" },
|
|
13
|
+
},
|
|
14
|
+
warning: {
|
|
15
|
+
textAccent: "high",
|
|
16
|
+
Input: { borderWidth: "small" },
|
|
17
|
+
},
|
|
18
|
+
success: {
|
|
19
|
+
textAccent: "high",
|
|
20
|
+
Button: { elevation: "none" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// ── Structure ──────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export const FillStyle = { Filled: "filled", Outlined: "outlined" } as const;
|
|
4
|
+
export type FillStyle = (typeof FillStyle)[keyof typeof FillStyle];
|
|
5
|
+
|
|
6
|
+
export const BorderRadius = {
|
|
7
|
+
None: "none",
|
|
8
|
+
Small: "small",
|
|
9
|
+
Medium: "medium",
|
|
10
|
+
Large: "large",
|
|
11
|
+
Full: "full",
|
|
12
|
+
} as const;
|
|
13
|
+
export type BorderRadius = (typeof BorderRadius)[keyof typeof BorderRadius];
|
|
14
|
+
|
|
15
|
+
export const BorderWidth = {
|
|
16
|
+
None: "none",
|
|
17
|
+
Small: "small",
|
|
18
|
+
Medium: "medium",
|
|
19
|
+
Large: "large",
|
|
20
|
+
} as const;
|
|
21
|
+
export type BorderWidth = (typeof BorderWidth)[keyof typeof BorderWidth];
|
|
22
|
+
|
|
23
|
+
export const Elevation = {
|
|
24
|
+
None: "none",
|
|
25
|
+
Small: "small",
|
|
26
|
+
Medium: "medium",
|
|
27
|
+
Large: "large",
|
|
28
|
+
} as const;
|
|
29
|
+
export type Elevation = (typeof Elevation)[keyof typeof Elevation];
|
|
30
|
+
|
|
31
|
+
export const Space = {
|
|
32
|
+
Small: "small",
|
|
33
|
+
Medium: "medium",
|
|
34
|
+
Large: "large",
|
|
35
|
+
} as const;
|
|
36
|
+
export type Space = (typeof Space)[keyof typeof Space];
|
|
37
|
+
|
|
38
|
+
export const Size = {
|
|
39
|
+
Small: "small",
|
|
40
|
+
Medium: "medium",
|
|
41
|
+
Large: "large",
|
|
42
|
+
} as const;
|
|
43
|
+
export type Size = (typeof Size)[keyof typeof Size];
|
|
44
|
+
|
|
45
|
+
// ── Emphasis ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export const TextAccent = {
|
|
48
|
+
Low: "low",
|
|
49
|
+
Medium: "medium",
|
|
50
|
+
High: "high",
|
|
51
|
+
} as const;
|
|
52
|
+
export type TextAccent = (typeof TextAccent)[keyof typeof TextAccent];
|
|
53
|
+
|
|
54
|
+
// ── Typography ─────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export const FontCategory = {
|
|
57
|
+
SansSerif: "sans-serif",
|
|
58
|
+
Serif: "serif",
|
|
59
|
+
Mono: "mono",
|
|
60
|
+
Slab: "slab",
|
|
61
|
+
Rounded: "rounded",
|
|
62
|
+
Condensed: "condensed",
|
|
63
|
+
Cursive: "cursive",
|
|
64
|
+
Handwriting: "handwriting",
|
|
65
|
+
Pixel: "pixel",
|
|
66
|
+
Blackletter: "blackletter",
|
|
67
|
+
Geometric: "geometric",
|
|
68
|
+
} as const;
|
|
69
|
+
export type FontCategory = (typeof FontCategory)[keyof typeof FontCategory];
|
|
70
|
+
|
|
71
|
+
export const HeadingFont = FontCategory;
|
|
72
|
+
export type HeadingFont = FontCategory;
|
|
73
|
+
|
|
74
|
+
export const BodyFont = FontCategory;
|
|
75
|
+
export type BodyFont = FontCategory;
|
|
76
|
+
|
|
77
|
+
export const FontWeight = {
|
|
78
|
+
Regular: "regular",
|
|
79
|
+
Bold: "bold",
|
|
80
|
+
} as const;
|
|
81
|
+
export type FontWeight = (typeof FontWeight)[keyof typeof FontWeight];
|
|
82
|
+
|
|
83
|
+
// ── Animation ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
import type { AnimationName } from "./animations/index";
|
|
86
|
+
|
|
87
|
+
export type Animation = "none" | AnimationName;
|
|
88
|
+
|
|
89
|
+
// ── Interaction state overrides ────────────────────────────
|
|
90
|
+
// Structural knobs that can vary per interaction state.
|
|
91
|
+
|
|
92
|
+
export interface StateKnobs {
|
|
93
|
+
fillStyle?: FillStyle;
|
|
94
|
+
borderRadius?: BorderRadius;
|
|
95
|
+
borderWidth?: BorderWidth;
|
|
96
|
+
elevation?: Elevation;
|
|
97
|
+
space?: Space;
|
|
98
|
+
textAccent?: TextAccent;
|
|
99
|
+
headingFont?: HeadingFont;
|
|
100
|
+
bodyFont?: BodyFont;
|
|
101
|
+
fontWeight?: FontWeight;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const interactionStates = ["hover", "press", "focus", "focusVisible"] as const;
|
|
105
|
+
export type InteractionState = (typeof interactionStates)[number];
|
|
106
|
+
|
|
107
|
+
// ── Knobs interface ────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface Knobs {
|
|
110
|
+
fillStyle: FillStyle;
|
|
111
|
+
borderRadius: BorderRadius;
|
|
112
|
+
borderWidth: BorderWidth;
|
|
113
|
+
elevation: Elevation;
|
|
114
|
+
space: Space;
|
|
115
|
+
size: Size;
|
|
116
|
+
textAccent: TextAccent;
|
|
117
|
+
headingFont: HeadingFont;
|
|
118
|
+
bodyFont: BodyFont;
|
|
119
|
+
fontWeight: FontWeight;
|
|
120
|
+
animation: Animation;
|
|
121
|
+
hover: StateKnobs;
|
|
122
|
+
press: StateKnobs;
|
|
123
|
+
focus: StateKnobs;
|
|
124
|
+
focusVisible: StateKnobs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Default knobs ──────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export const emptyStateKnobs: StateKnobs = {};
|
|
130
|
+
|
|
131
|
+
export const defaultKnobs: Knobs = {
|
|
132
|
+
fillStyle: "filled",
|
|
133
|
+
borderRadius: "medium",
|
|
134
|
+
borderWidth: "small",
|
|
135
|
+
elevation: "none",
|
|
136
|
+
space: "medium",
|
|
137
|
+
size: "medium",
|
|
138
|
+
textAccent: "high",
|
|
139
|
+
headingFont: "sans-serif",
|
|
140
|
+
bodyFont: "sans-serif",
|
|
141
|
+
fontWeight: "regular",
|
|
142
|
+
animation: "quick",
|
|
143
|
+
hover: emptyStateKnobs,
|
|
144
|
+
press: emptyStateKnobs,
|
|
145
|
+
focus: emptyStateKnobs,
|
|
146
|
+
focusVisible: emptyStateKnobs,
|
|
147
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Group 5.1 — Pokemon screen source-code analysis tests.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that the refactored Pokemon files follow the
|
|
5
|
+
* Tamagui-native Card pattern and contain zero hardcoded styling values.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(__dirname, "../../../..");
|
|
16
|
+
|
|
17
|
+
function readSource(relativePath: string): string {
|
|
18
|
+
return fs.readFileSync(path.resolve(root, relativePath), "utf-8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns all non-comment, non-import lines from a source file.
|
|
23
|
+
* Strips single-line comments and block comments for cleaner matching.
|
|
24
|
+
*/
|
|
25
|
+
function codeLines(source: string): string[] {
|
|
26
|
+
return source
|
|
27
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // strip block comments
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((l) => l.replace(/\/\/.*$/, "").trim()) // strip line comments
|
|
30
|
+
.filter((l) => l.length > 0 && !l.startsWith("import ") && !l.startsWith("export type"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Pokemon source files under test
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const pokemonFiles = [
|
|
38
|
+
"features/pokemon/screen.tsx",
|
|
39
|
+
"features/pokemon/detail.tsx",
|
|
40
|
+
"features/pokemon/components/PokemonCard.tsx",
|
|
41
|
+
"features/pokemon/components/MoveList.tsx",
|
|
42
|
+
"features/pokemon/components/AbilityList.tsx",
|
|
43
|
+
"features/pokemon/components/TypeList.tsx",
|
|
44
|
+
"features/pokemon/components/PokemonStats.tsx",
|
|
45
|
+
"public/frappe-ui/src/FrappeImageView.tsx",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Tests
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe("Pokemon screen — Card rendering with native Tamagui props", () => {
|
|
53
|
+
it("screen.tsx uses Card with elevation, borderWidth, and Card.Background/Card.Footer", () => {
|
|
54
|
+
const src = readSource("features/pokemon/screen.tsx");
|
|
55
|
+
expect(src).toContain("<Card");
|
|
56
|
+
expect(src).toMatch(/elevation/);
|
|
57
|
+
expect(src).toMatch(/borderWidth/);
|
|
58
|
+
expect(src).toContain("Card.Background");
|
|
59
|
+
expect(src).toContain("Card.Footer");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("screen.tsx uses ToggleGroup for view switcher instead of manual Button toggles", () => {
|
|
63
|
+
const src = readSource("features/pokemon/screen.tsx");
|
|
64
|
+
expect(src).toContain("ToggleGroup");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("FrappeImageView.tsx uses Card with elevation, borderWidth, and sizeToken", () => {
|
|
68
|
+
const src = readSource("public/frappe-ui/src/FrappeImageView.tsx");
|
|
69
|
+
expect(src).toContain("<Card");
|
|
70
|
+
expect(src).toMatch(/elevation/);
|
|
71
|
+
expect(src).toMatch(/borderWidth/);
|
|
72
|
+
expect(src).toContain("sizeToken");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("Pokemon screen — no hardcoded styling values", () => {
|
|
77
|
+
for (const filePath of pokemonFiles) {
|
|
78
|
+
it(`${filePath} has no hardcoded borderRadius, padding, gap, or elevation overrides`, () => {
|
|
79
|
+
const src = readSource(filePath);
|
|
80
|
+
const lines = codeLines(src);
|
|
81
|
+
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
// Skip lines that are prop definitions on Card/etc (elevate, bordered are fine)
|
|
84
|
+
// Skip lines that are Tamagui token references like "$3", "$4"
|
|
85
|
+
// We're looking for raw numeric values assigned to styling props
|
|
86
|
+
|
|
87
|
+
// No hardcoded borderRadius with raw numbers (e.g. borderRadius={12})
|
|
88
|
+
expect(line).not.toMatch(/borderRadius[=:{]\s*\d+(?!\s*\})/);
|
|
89
|
+
|
|
90
|
+
// No manual elevation={number} (should use `elevate` boolean instead)
|
|
91
|
+
// Allow elevation on Progress.Indicator or as Tamagui token strings
|
|
92
|
+
if (!line.includes("Progress")) {
|
|
93
|
+
expect(line).not.toMatch(/elevation[=:{]\s*\d+/);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// No spreading of borderRadiusProps (Card handles via size)
|
|
97
|
+
expect(line).not.toMatch(/\.\.\.themeProps\.borderRadiusProps/);
|
|
98
|
+
|
|
99
|
+
// No spreading of elevationProps (Card handles via elevate)
|
|
100
|
+
expect(line).not.toMatch(/\.\.\.themeProps\.elevationProps/);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it("screen.tsx uses sizeToken on Card components", () => {
|
|
106
|
+
const src = readSource("features/pokemon/screen.tsx");
|
|
107
|
+
expect(src).toContain("sizeToken");
|
|
108
|
+
});
|
|
109
|
+
});
|