1import { assert, describe, expect } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const guardedMobileFile = createOxlintRuleHarness("t3code/no-mobile-uniwind-theme-escape-hatches", {
6 filename: "apps/mobile/src/features/settings/NewThemeSurface.tsx",
7});
8const reviewedInteropFile = createOxlintRuleHarness(
9 "t3code/no-mobile-uniwind-theme-escape-hatches",
10 {
11 filename: "apps/mobile/src/features/home/HomeHeader.tsx",
12 ruleOptions: [{ allowUniwindTheme: true }],
13 },
14);
15
16describe("t3code/no-mobile-uniwind-theme-escape-hatches", () => {
17 guardedMobileFile.valid(
18 "allows semantic mobile theme classes",
19 `const surface = <View className="bg-surface text-foreground" />;`,
20 );
21
22 guardedMobileFile.valid(
23 "allows unrelated functions with legacy hook names",
24 `
25 const useCSSVariable = () => "local value";
26 const useThemeColor = () => "local color";
27
28 export const values = [useCSSVariable(), useThemeColor()];
29 `,
30 );
31
32 guardedMobileFile.valid(
33 "allows unrelated imports with legacy hook names",
34 `
35 import { useThemeColor } from "./unrelated-library";
36
37 export const foreground = useThemeColor();
38 `,
39 );
40
41 guardedMobileFile.valid(
42 "allows erased type-only theme imports",
43 `
44 import type { useCSSVariable } from "uniwind";
45 import { type useCSSVariable as CSSVariableHook } from "uniwind";
46 import type { useThemeColor } from "../../../hooks/useThemeColor";
47 import type { useUniwindTheme } from "../../../lib/useUniwindTheme";
48
49 export type ThemeHooks = [
50 typeof useCSSVariable,
51 typeof CSSVariableHook,
52 typeof useThemeColor,
53 typeof useUniwindTheme,
54 ];
55 `,
56 );
57
58 guardedMobileFile.valid(
59 "allows appearance words in ordinary copy",
60 `
61 const message = "Set dark mode manually";
62 const detail = "For dark: high contrast mode is available";
63 const label = <Text accessibilityLabel="Light theme">Theme</Text>;
64 `,
65 );
66
67 guardedMobileFile.valid(
68 "does not conflate shadowed Uniwind namespaces",
69 `
70 import * as Uniwind from "uniwind";
71
72 export function readDirect(Uniwind: { useCSSVariable: () => string }) {
73 return Uniwind.useCSSVariable();
74 }
75
76 export function readDestructured(Uniwind: { useCSSVariable: () => string }) {
77 const { useCSSVariable } = Uniwind;
78 return useCSSVariable();
79 }
80 `,
81 );
82
83 reviewedInteropFile.valid(
84 "allows reviewed native interop boundaries",
85 `
86 import { useUniwindTheme } from "../../lib/useUniwindTheme";
87
88 export const foreground = useUniwindTheme().colors.foreground;
89 `,
90 );
91
92 reviewedInteropFile.invalid(
93 "still reports appearance variants in reviewed interop boundaries",
94 `const surface = <View className="bg-white dark:bg-black" />;`,
95 );
96
97 guardedMobileFile.invalid(
98 "reports new React theme subscriptions",
99 `
100 import { useCSSVariable } from "uniwind";
101
102 export const foreground = useCSSVariable("--color-foreground");
103 `,
104 (output) => {
105 assert.match(output, /semantic className/);
106 },
107 );
108
109 guardedMobileFile.invalid(
110 "reports the retired theme color hook",
111 `
112 import { useThemeColor } from "../../../hooks/useThemeColor";
113
114 export const foreground = useThemeColor({}, "foreground");
115 `,
116 (output) => {
117 assert.match(output, /replaced by semantic Uniwind classes/);
118 },
119 );
120
121 guardedMobileFile.invalid(
122 "reports guarded hooks imported with TypeScript extensions",
123 `
124 import { useThemeColor } from "../../../hooks/useThemeColor.ts";
125 import { useUniwindTheme } from "../../../lib/useUniwindTheme.ts";
126
127 export const foreground = [
128 useThemeColor({}, "foreground"),
129 useUniwindTheme().colors.foreground,
130 ];
131 `,
132 (output) => {
133 assert.match(output, /replaced by semantic Uniwind classes/);
134 assert.match(output, /native\/third-party interop boundary/);
135 },
136 );
137
138 guardedMobileFile.invalid(
139 "reports unreviewed native interop subscriptions",
140 `
141 import { useUniwindTheme } from "../../../lib/useUniwindTheme";
142
143 export const foreground = useUniwindTheme().colors.foreground;
144 `,
145 (output) => {
146 assert.match(output, /native\/third-party interop boundary/);
147 },
148 );
149
150 guardedMobileFile.invalid(
151 "reports appearance variants in string literals",
152 `const surface = <View className="bg-white dark:bg-black" />;`,
153 (output) => {
154 assert.match(output, /registered custom themes/);
155 },
156 );
157
158 guardedMobileFile.invalid(
159 "reports appearance variants in template literals",
160 "const className = `bg-black light:bg-white`;",
161 );
162
163 guardedMobileFile.invalid(
164 "reports escaped appearance variants in template literals",
165 "const className = `dark\\u003abg-black`;",
166 );
167
168 guardedMobileFile.invalid(
169 "reports appearance variants through nested class-map indirection",
170 `
171 const styles = {
172 variants: { root: "bg-white dark:bg-black" },
173 };
174
175 const root = styles.variants.root;
176 export const surface = <View className={root} />;
177 `,
178 (output) => {
179 expect(output.match(/registered custom themes/g)).toHaveLength(1);
180 },
181 );
182
183 guardedMobileFile.invalid(
184 "reports appearance variants passed to class builders",
185 `const surface = cn("bg-white", enabled && "dark:bg-black");`,
186 );
187
188 guardedMobileFile.invalid(
189 "reports negative and important appearance variants",
190 `const className = "dark:-mt-2 light:!bg-white";`,
191 );
192
193 guardedMobileFile.invalid(
194 "reports namespace CSS variable subscriptions",
195 `
196 import * as Uniwind from "uniwind";
197
198 export const foreground = Uniwind.useCSSVariable("--color-foreground");
199 `,
200 );
201
202 guardedMobileFile.invalid(
203 "reports destructured namespace CSS variable subscriptions",
204 `
205 import * as Uniwind from "uniwind";
206
207 const { useCSSVariable: resolveVariable } = Uniwind;
208 export const foreground = resolveVariable("--color-foreground");
209 `,
210 );
211
212 guardedMobileFile.invalid(
213 "reports aliased namespace CSS variable subscriptions",
214 `
215 import * as Uniwind from "uniwind";
216
217 const Theme = Uniwind;
218 const NestedTheme = Theme;
219 export const foreground = NestedTheme.useCSSVariable("--color-foreground");
220 `,
221 );
222
223 guardedMobileFile.invalid(
224 "reports object-rest namespace CSS variable subscriptions",
225 `
226 import * as Uniwind from "uniwind";
227
228 const { ...Theme } = Uniwind;
229 export const foreground = Theme.useCSSVariable("--color-foreground");
230 `,
231 );
232
233 guardedMobileFile.invalid(
234 "reports namespace access to the retired theme hook",
235 `
236 import * as ThemeColor from "../../../hooks/useThemeColor";
237
238 export const foreground = ThemeColor.useThemeColor({}, "foreground");
239 `,
240 );
241
242 guardedMobileFile.invalid(
243 "reports destructured namespace access to the retired theme hook",
244 `
245 import * as ThemeColor from "../../../hooks/useThemeColor.ts";
246
247 const { useThemeColor: resolveThemeColor } = ThemeColor;
248 export const foreground = resolveThemeColor({}, "foreground");
249 `,
250 );
251});