@blotch/dolan 0.0.0-stage → 22.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/dist/commands/add.d.ts +22 -0
  2. package/dist/commands/add.js +132 -0
  3. package/dist/commands/add.js.map +1 -0
  4. package/dist/commands/add.test.d.ts +1 -0
  5. package/dist/commands/add.test.js +209 -0
  6. package/dist/commands/add.test.js.map +1 -0
  7. package/dist/commands/diff.d.ts +13 -0
  8. package/dist/commands/diff.js +155 -0
  9. package/dist/commands/diff.js.map +1 -0
  10. package/dist/commands/diff.test.d.ts +1 -0
  11. package/dist/commands/diff.test.js +327 -0
  12. package/dist/commands/diff.test.js.map +1 -0
  13. package/dist/commands/doctor.d.ts +15 -0
  14. package/dist/commands/doctor.js +248 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/doctor.test.d.ts +1 -0
  17. package/dist/commands/doctor.test.js +301 -0
  18. package/dist/commands/doctor.test.js.map +1 -0
  19. package/dist/commands/eject.d.ts +11 -0
  20. package/dist/commands/eject.js +55 -0
  21. package/dist/commands/eject.js.map +1 -0
  22. package/dist/commands/eject.test.d.ts +1 -0
  23. package/dist/commands/eject.test.js +151 -0
  24. package/dist/commands/eject.test.js.map +1 -0
  25. package/dist/commands/info.d.ts +1 -0
  26. package/dist/commands/info.js +73 -0
  27. package/dist/commands/info.js.map +1 -0
  28. package/dist/commands/info.test.d.ts +1 -0
  29. package/dist/commands/info.test.js +143 -0
  30. package/dist/commands/info.test.js.map +1 -0
  31. package/dist/commands/init.d.ts +1 -0
  32. package/dist/commands/init.js +98 -0
  33. package/dist/commands/init.js.map +1 -0
  34. package/dist/commands/init.test.d.ts +1 -0
  35. package/dist/commands/init.test.js +154 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/list.d.ts +16 -0
  38. package/dist/commands/list.js +124 -0
  39. package/dist/commands/list.js.map +1 -0
  40. package/dist/commands/list.test.d.ts +1 -0
  41. package/dist/commands/list.test.js +285 -0
  42. package/dist/commands/list.test.js.map +1 -0
  43. package/dist/commands/outdated.d.ts +13 -0
  44. package/dist/commands/outdated.js +116 -0
  45. package/dist/commands/outdated.js.map +1 -0
  46. package/dist/commands/outdated.test.d.ts +1 -0
  47. package/dist/commands/outdated.test.js +208 -0
  48. package/dist/commands/outdated.test.js.map +1 -0
  49. package/dist/commands/remove.d.ts +18 -0
  50. package/dist/commands/remove.js +161 -0
  51. package/dist/commands/remove.js.map +1 -0
  52. package/dist/commands/remove.test.d.ts +1 -0
  53. package/dist/commands/remove.test.js +361 -0
  54. package/dist/commands/remove.test.js.map +1 -0
  55. package/dist/commands/theme.d.ts +1 -0
  56. package/dist/commands/theme.js +66 -0
  57. package/dist/commands/theme.js.map +1 -0
  58. package/dist/commands/theme.test.d.ts +1 -0
  59. package/dist/commands/theme.test.js +133 -0
  60. package/dist/commands/theme.test.js.map +1 -0
  61. package/dist/commands/update.d.ts +31 -0
  62. package/dist/commands/update.js +601 -0
  63. package/dist/commands/update.js.map +1 -0
  64. package/dist/commands/update.test.d.ts +1 -0
  65. package/dist/commands/update.test.js +1233 -0
  66. package/dist/commands/update.test.js.map +1 -0
  67. package/dist/config.d.ts +11 -0
  68. package/dist/config.js +19 -0
  69. package/dist/config.js.map +1 -0
  70. package/dist/config.test.d.ts +1 -0
  71. package/dist/config.test.js +48 -0
  72. package/dist/config.test.js.map +1 -0
  73. package/dist/index.d.ts +2 -0
  74. package/dist/index.js +89 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/lockfile.d.ts +21 -0
  77. package/dist/lockfile.js +39 -0
  78. package/dist/lockfile.js.map +1 -0
  79. package/dist/registry.d.ts +14 -0
  80. package/dist/registry.js +85 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/registry.test.d.ts +1 -0
  83. package/dist/registry.test.js +95 -0
  84. package/dist/registry.test.js.map +1 -0
  85. package/dist/test-utils.d.ts +16 -0
  86. package/dist/test-utils.js +65 -0
  87. package/dist/test-utils.js.map +1 -0
  88. package/dist/ui/components/accordion/accordion.test.ts +194 -0
  89. package/dist/ui/components/accordion/accordion.ts +173 -0
  90. package/dist/ui/components/accordion/index.ts +6 -0
  91. package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
  92. package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
  93. package/dist/ui/components/action-sheet/index.ts +6 -0
  94. package/dist/ui/components/alert/alert.test.ts +48 -0
  95. package/dist/ui/components/alert/alert.ts +104 -0
  96. package/dist/ui/components/alert/index.ts +1 -0
  97. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
  98. package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
  99. package/dist/ui/components/alert-dialog/index.ts +7 -0
  100. package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
  101. package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
  102. package/dist/ui/components/area-chart/area-chart.ts +349 -0
  103. package/dist/ui/components/area-chart/index.ts +6 -0
  104. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
  105. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
  106. package/dist/ui/components/aspect-ratio/index.ts +1 -0
  107. package/dist/ui/components/avatar/avatar.test.ts +69 -0
  108. package/dist/ui/components/avatar/avatar.ts +131 -0
  109. package/dist/ui/components/avatar/index.ts +1 -0
  110. package/dist/ui/components/badge/badge.test.ts +49 -0
  111. package/dist/ui/components/badge/badge.ts +97 -0
  112. package/dist/ui/components/badge/index.ts +1 -0
  113. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
  114. package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
  115. package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
  116. package/dist/ui/components/bar-chart/index.ts +6 -0
  117. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
  118. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
  119. package/dist/ui/components/bottom-sheet/index.ts +1 -0
  120. package/dist/ui/components/button/button-render.test.ts +113 -0
  121. package/dist/ui/components/button/button.test.ts +70 -0
  122. package/dist/ui/components/button/button.ts +170 -0
  123. package/dist/ui/components/button/index.ts +6 -0
  124. package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
  125. package/dist/ui/components/button-group/button-group.test.ts +54 -0
  126. package/dist/ui/components/button-group/button-group.ts +209 -0
  127. package/dist/ui/components/button-group/index.ts +8 -0
  128. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
  129. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
  130. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
  131. package/dist/ui/components/candlestick-chart/index.ts +7 -0
  132. package/dist/ui/components/card/card.test.ts +110 -0
  133. package/dist/ui/components/card/card.ts +162 -0
  134. package/dist/ui/components/card/index.ts +8 -0
  135. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
  136. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
  137. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
  138. package/dist/ui/components/cartesian-chart/index.ts +18 -0
  139. package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
  140. package/dist/ui/components/checkbox/checkbox.ts +96 -0
  141. package/dist/ui/components/checkbox/index.ts +1 -0
  142. package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
  143. package/dist/ui/components/collapsible/collapsible.ts +122 -0
  144. package/dist/ui/components/collapsible/index.ts +5 -0
  145. package/dist/ui/components/dialog/dialog.test.ts +157 -0
  146. package/dist/ui/components/dialog/dialog.ts +248 -0
  147. package/dist/ui/components/dialog/index.ts +7 -0
  148. package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
  149. package/dist/ui/components/empty-state/empty-state.ts +46 -0
  150. package/dist/ui/components/empty-state/index.ts +1 -0
  151. package/dist/ui/components/icon/icon.test.ts +52 -0
  152. package/dist/ui/components/icon/icon.ts +54 -0
  153. package/dist/ui/components/icon/icons.ts +569 -0
  154. package/dist/ui/components/icon/index.ts +2 -0
  155. package/dist/ui/components/input/index.ts +1 -0
  156. package/dist/ui/components/input/input.test.ts +134 -0
  157. package/dist/ui/components/input/input.ts +189 -0
  158. package/dist/ui/components/label/index.ts +1 -0
  159. package/dist/ui/components/label/label.test.ts +38 -0
  160. package/dist/ui/components/label/label.ts +24 -0
  161. package/dist/ui/components/line-chart/index.ts +6 -0
  162. package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
  163. package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
  164. package/dist/ui/components/line-chart/line-chart.ts +273 -0
  165. package/dist/ui/components/list/index.ts +1 -0
  166. package/dist/ui/components/list/list.test.ts +35 -0
  167. package/dist/ui/components/list/list.ts +71 -0
  168. package/dist/ui/components/nav-drawer/index.ts +8 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
  171. package/dist/ui/components/pagination/index.ts +1 -0
  172. package/dist/ui/components/pagination/pagination.test.ts +96 -0
  173. package/dist/ui/components/pagination/pagination.ts +147 -0
  174. package/dist/ui/components/progress/index.ts +1 -0
  175. package/dist/ui/components/progress/progress.test.ts +71 -0
  176. package/dist/ui/components/progress/progress.ts +118 -0
  177. package/dist/ui/components/radio-group/index.ts +1 -0
  178. package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
  179. package/dist/ui/components/radio-group/radio-group.ts +129 -0
  180. package/dist/ui/components/scatter-chart/index.ts +6 -0
  181. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
  182. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
  183. package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
  184. package/dist/ui/components/scroll-area/index.ts +1 -0
  185. package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
  186. package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
  187. package/dist/ui/components/select/index.ts +1 -0
  188. package/dist/ui/components/select/select.test.ts +107 -0
  189. package/dist/ui/components/select/select.ts +146 -0
  190. package/dist/ui/components/separator/index.ts +1 -0
  191. package/dist/ui/components/separator/separator.test.ts +26 -0
  192. package/dist/ui/components/separator/separator.ts +45 -0
  193. package/dist/ui/components/sheet/index.ts +7 -0
  194. package/dist/ui/components/sheet/sheet.test.ts +44 -0
  195. package/dist/ui/components/sheet/sheet.ts +114 -0
  196. package/dist/ui/components/skeleton/index.ts +1 -0
  197. package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
  198. package/dist/ui/components/skeleton/skeleton.ts +45 -0
  199. package/dist/ui/components/spinner/index.ts +1 -0
  200. package/dist/ui/components/spinner/spinner.test.ts +39 -0
  201. package/dist/ui/components/spinner/spinner.ts +83 -0
  202. package/dist/ui/components/switch/index.ts +1 -0
  203. package/dist/ui/components/switch/switch.test.ts +63 -0
  204. package/dist/ui/components/switch/switch.ts +98 -0
  205. package/dist/ui/components/tabs/index.ts +1 -0
  206. package/dist/ui/components/tabs/tabs.test.ts +199 -0
  207. package/dist/ui/components/tabs/tabs.ts +317 -0
  208. package/dist/ui/components/textarea/index.ts +1 -0
  209. package/dist/ui/components/textarea/textarea.test.ts +138 -0
  210. package/dist/ui/components/textarea/textarea.ts +221 -0
  211. package/dist/ui/components/toast/index.ts +9 -0
  212. package/dist/ui/components/toast/toast-state.test.ts +54 -0
  213. package/dist/ui/components/toast/toast-state.ts +46 -0
  214. package/dist/ui/components/toast/toast.test.ts +342 -0
  215. package/dist/ui/components/toast/toast.ts +527 -0
  216. package/dist/ui/components/toggle/index.ts +7 -0
  217. package/dist/ui/components/toggle/toggle.test.ts +186 -0
  218. package/dist/ui/components/toggle/toggle.ts +218 -0
  219. package/dist/ui/components/typography/index.ts +5 -0
  220. package/dist/ui/components/typography/typography.test.ts +55 -0
  221. package/dist/ui/components/typography/typography.ts +44 -0
  222. package/dist/ui/theme/dark.css +41 -0
  223. package/dist/ui/theme/default.css +56 -0
  224. package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
  225. package/dist/ui/theme/tailwind-plugin.ts +151 -0
  226. package/dist/ui/utils/animate.test.ts +324 -0
  227. package/dist/ui/utils/animate.ts +544 -0
  228. package/dist/ui/utils/cn.test.ts +27 -0
  229. package/dist/ui/utils/cn.ts +6 -0
  230. package/dist/utils/analyze.d.ts +32 -0
  231. package/dist/utils/analyze.js +109 -0
  232. package/dist/utils/analyze.js.map +1 -0
  233. package/dist/utils/analyze.test.d.ts +1 -0
  234. package/dist/utils/analyze.test.js +146 -0
  235. package/dist/utils/analyze.test.js.map +1 -0
  236. package/dist/utils/animate.d.ts +210 -0
  237. package/dist/utils/animate.js +423 -0
  238. package/dist/utils/animate.js.map +1 -0
  239. package/dist/utils/animate.test.d.ts +1 -0
  240. package/dist/utils/animate.test.js +365 -0
  241. package/dist/utils/animate.test.js.map +1 -0
  242. package/dist/utils/banner.d.ts +1 -0
  243. package/dist/utils/banner.js +15 -0
  244. package/dist/utils/banner.js.map +1 -0
  245. package/dist/utils/banner.test.d.ts +1 -0
  246. package/dist/utils/banner.test.js +29 -0
  247. package/dist/utils/banner.test.js.map +1 -0
  248. package/dist/utils/cn.d.ts +2 -0
  249. package/dist/utils/cn.js +6 -0
  250. package/dist/utils/cn.js.map +1 -0
  251. package/dist/utils/cn.test.d.ts +1 -0
  252. package/dist/utils/cn.test.js +21 -0
  253. package/dist/utils/cn.test.js.map +1 -0
  254. package/dist/utils/format.d.ts +31 -0
  255. package/dist/utils/format.js +95 -0
  256. package/dist/utils/format.js.map +1 -0
  257. package/dist/utils/format.test.d.ts +1 -0
  258. package/dist/utils/format.test.js +71 -0
  259. package/dist/utils/format.test.js.map +1 -0
  260. package/dist/utils/logger.d.ts +6 -0
  261. package/dist/utils/logger.js +16 -0
  262. package/dist/utils/logger.js.map +1 -0
  263. package/dist/utils/logger.test.d.ts +1 -0
  264. package/dist/utils/logger.test.js +36 -0
  265. package/dist/utils/logger.test.js.map +1 -0
  266. package/dist/utils/resolve-paths.d.ts +12 -0
  267. package/dist/utils/resolve-paths.js +29 -0
  268. package/dist/utils/resolve-paths.js.map +1 -0
  269. package/dist/utils/resolve-paths.test.d.ts +1 -0
  270. package/dist/utils/resolve-paths.test.js +52 -0
  271. package/dist/utils/resolve-paths.test.js.map +1 -0
  272. package/package.json +56 -4
  273. package/README.md +0 -3
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { addSafeAreaUtilities, blotchPlugin } from './tailwind-plugin';
4
+
5
+ /**
6
+ * These tests exercise the utility generator in isolation with mock Tailwind
7
+ * plugin callbacks — no real Tailwind runtime is needed. We assert the exact
8
+ * CSS strings emitted so a regression in the `env()` / `calc()` wiring (which is
9
+ * what makes safe area work on Lynx) is caught immediately.
10
+ */
11
+ const setup = () => {
12
+ const addUtilities = vi.fn();
13
+ const matchUtilities = vi.fn();
14
+ // Minimal spacing scale — enough to prove offset utilities read the theme.
15
+ const spacing = { '0': '0px', '2': '0.5rem', '4': '1rem' };
16
+ const theme = vi.fn((key: string) =>
17
+ key === 'spacing' ? spacing : undefined,
18
+ );
19
+
20
+ addSafeAreaUtilities({
21
+ addUtilities: addUtilities as never,
22
+ matchUtilities: matchUtilities as never,
23
+ theme: theme as never,
24
+ });
25
+
26
+ return { addUtilities, matchUtilities, spacing };
27
+ };
28
+
29
+ describe('addSafeAreaUtilities', () => {
30
+ it('registers bare padding utilities equal to the inset', () => {
31
+ const { addUtilities } = setup();
32
+ const utilities = addUtilities.mock.calls[0][0];
33
+
34
+ expect(utilities['.pt-safe']).toEqual({
35
+ 'padding-top': 'env(safe-area-inset-top)',
36
+ });
37
+ expect(utilities['.pb-safe']).toEqual({
38
+ 'padding-bottom': 'env(safe-area-inset-bottom)',
39
+ });
40
+ expect(utilities['.px-safe']).toEqual({
41
+ 'padding-left': 'env(safe-area-inset-left)',
42
+ 'padding-right': 'env(safe-area-inset-right)',
43
+ });
44
+ expect(utilities['.p-safe']).toEqual({
45
+ 'padding-top': 'env(safe-area-inset-top)',
46
+ 'padding-right': 'env(safe-area-inset-right)',
47
+ 'padding-bottom': 'env(safe-area-inset-bottom)',
48
+ 'padding-left': 'env(safe-area-inset-left)',
49
+ });
50
+ });
51
+
52
+ it('registers one offset family per side, seeded from the spacing scale', () => {
53
+ const { matchUtilities, spacing } = setup();
54
+
55
+ // One matchUtilities call per physical side.
56
+ expect(matchUtilities).toHaveBeenCalledTimes(4);
57
+
58
+ // Every call must feed off the theme spacing scale so the safe offsets stay
59
+ // in sync with the normal padding scale.
60
+ for (const [, options] of matchUtilities.mock.calls) {
61
+ expect(options).toEqual({ values: spacing });
62
+ }
63
+ });
64
+
65
+ it('offset utilities compose base spacing with the inset via calc()', () => {
66
+ const { matchUtilities } = setup();
67
+
68
+ // Find the bottom family (`pb-safe`) and invoke its generator.
69
+ const bottomCall = matchUtilities.mock.calls.find(
70
+ ([utils]) => 'pb-safe' in utils,
71
+ );
72
+ expect(bottomCall).toBeDefined();
73
+
74
+ const generate = bottomCall![0]['pb-safe'];
75
+ expect(generate('1rem')).toEqual({
76
+ 'padding-bottom': 'calc(1rem + env(safe-area-inset-bottom))',
77
+ });
78
+ });
79
+ });
80
+
81
+ describe('blotchPlugin', () => {
82
+ it('runs the plugin setup callback, registering base styles and safe area utilities', () => {
83
+ // Reach the plugin's `handler` (the first arg to the tailwind `plugin()`
84
+ // factory) so its `addBase` + `addSafeAreaUtilities` calls are covered.
85
+ const addBase = vi.fn();
86
+ const addUtilities = vi.fn();
87
+ const matchUtilities = vi.fn();
88
+ const theme = vi.fn(() => ({ '4': '1rem' }));
89
+
90
+ // The tailwindcss `plugin()` factory returns a `{ handler, config }`
91
+ // object at runtime. Cast through unknown so TypeScript accepts the
92
+ // internal shape.
93
+ const factory = blotchPlugin as unknown as {
94
+ handler: (api: unknown) => void;
95
+ config?: unknown;
96
+ };
97
+
98
+ factory.handler({
99
+ addBase,
100
+ addUtilities,
101
+ matchUtilities,
102
+ theme,
103
+ });
104
+
105
+ expect(addBase).toHaveBeenCalledWith({
106
+ '*': { 'border-color': 'var(--border)' },
107
+ });
108
+ // The safe area utilities came through — bare + matched variants added.
109
+ expect(addUtilities).toHaveBeenCalled();
110
+ expect(matchUtilities).toHaveBeenCalled();
111
+ });
112
+
113
+ it('exposes a config object containing the theme extension colors', () => {
114
+ // The second argument to `plugin()` is a config object that Tailwind
115
+ // deep-merges into the user's config. Verify our theme extensions round-
116
+ // trip so the colors are exposed to the utility classes.
117
+ const factory = blotchPlugin as unknown as {
118
+ config?: { theme?: { extend?: { colors?: Record<string, unknown> } } };
119
+ };
120
+ const colors = factory.config?.theme?.extend?.colors;
121
+ expect(colors).toBeDefined();
122
+ expect(colors!['background']).toBe('var(--background)');
123
+ });
124
+ });
@@ -0,0 +1,151 @@
1
+ import plugin from 'tailwindcss/plugin';
2
+ import type { PluginAPI } from 'tailwindcss/types/config';
3
+
4
+ /**
5
+ * The four physical sides, paired with their safe-area `env()` inset. Used to
6
+ * generate the padding utilities below without repeating the mapping.
7
+ */
8
+ const SAFE_AREA_SIDES = {
9
+ t: { prop: 'padding-top', inset: 'env(safe-area-inset-top)' },
10
+ r: { prop: 'padding-right', inset: 'env(safe-area-inset-right)' },
11
+ b: { prop: 'padding-bottom', inset: 'env(safe-area-inset-bottom)' },
12
+ l: { prop: 'padding-left', inset: 'env(safe-area-inset-left)' },
13
+ } as const;
14
+
15
+ /**
16
+ * Registers safe-area-aware padding utilities driven by CSS `env(safe-area-inset-*)`.
17
+ *
18
+ * Why `env()` works on Lynx: the native CSS engine parses `env(safe-area-inset-*)`
19
+ * as a length (see references/lynx .../css_style_utils.cc `GetEnvValue`), and its
20
+ * calc parser accepts `env()` *inside* `calc()` too — so `calc(1rem + env(...))`
21
+ * is valid on device. The inset values default to `0` until the native host app
22
+ * populates them, so every utility here degrades gracefully to plain padding
23
+ * (the offset variants) or to no padding (the bare variants).
24
+ *
25
+ * Two flavors:
26
+ * - Bare (`.pb-safe`, `.px-safe`, `.p-safe`, …) — padding equal to the inset alone.
27
+ * - Offset (`.pb-safe-4`, `.pt-safe-2`, …) — a spacing-scale value PLUS the inset,
28
+ * e.g. `calc(1rem + env(safe-area-inset-bottom))`. Use these when an element
29
+ * already wants base padding but must also clear the notch/home indicator
30
+ * (the toast wrapper is the canonical case).
31
+ */
32
+ export const addSafeAreaUtilities = ({
33
+ addUtilities,
34
+ matchUtilities,
35
+ theme,
36
+ }: Pick<PluginAPI, 'addUtilities' | 'matchUtilities' | 'theme'>): void => {
37
+ const { t, r, b, l } = SAFE_AREA_SIDES;
38
+
39
+ // Bare utilities: padding = the inset itself.
40
+ addUtilities({
41
+ '.pt-safe': { [t.prop]: t.inset },
42
+ '.pr-safe': { [r.prop]: r.inset },
43
+ '.pb-safe': { [b.prop]: b.inset },
44
+ '.pl-safe': { [l.prop]: l.inset },
45
+ '.px-safe': { [l.prop]: l.inset, [r.prop]: r.inset },
46
+ '.py-safe': { [t.prop]: t.inset, [b.prop]: b.inset },
47
+ '.p-safe': {
48
+ [t.prop]: t.inset,
49
+ [r.prop]: r.inset,
50
+ [b.prop]: b.inset,
51
+ [l.prop]: l.inset,
52
+ },
53
+ });
54
+
55
+ // Offset utilities: base spacing + the inset, one family per side. Generated
56
+ // from the theme spacing scale so `pb-safe-4`, `pt-safe-2`, etc. all exist and
57
+ // stay in sync with the rest of the padding scale.
58
+ const spacing = theme('spacing') as Record<string, string>;
59
+ for (const { prop, inset } of [t, r, b, l]) {
60
+ const side = prop.replace('padding-', '')[0]; // 't' | 'r' | 'b' | 'l'
61
+ matchUtilities(
62
+ {
63
+ [`p${side}-safe`]: (value) => ({
64
+ [prop]: `calc(${value} + ${inset})`,
65
+ }),
66
+ },
67
+ { values: spacing },
68
+ );
69
+ }
70
+ };
71
+
72
+ /**
73
+ * Tailwind plugin that maps @blotch/ui CSS variables to semantic color utilities
74
+ * and registers safe-area-aware padding utilities.
75
+ *
76
+ * Usage in tailwind.config.ts:
77
+ * import { blotchPlugin } from '@blotch/ui/theme/tailwind-plugin';
78
+ * export default { plugins: [blotchPlugin] };
79
+ *
80
+ * Colors reference CSS variables DIRECTLY (`var(--x)`) rather than the shadcn/ui
81
+ * pattern of wrapping raw HSL channels in `hsl(var(--x) / <alpha-value>)`. Lynx's
82
+ * native CSS engine only parses comma-separated hsl(); Tailwind emits the
83
+ * space-separated form `hsl(var(--x) / 1)`, which Lynx silently drops (colors
84
+ * render transparent on device while looking fine in the web preview). Storing
85
+ * complete rgba() values in the variables (see theme/default.css) and
86
+ * referencing them directly avoids color-function parsing entirely. This matches
87
+ * the official React Lynx Tailwind example.
88
+ *
89
+ * Tradeoff: Tailwind opacity modifiers on semantic colors (bg-primary/50) don't
90
+ * work — the value is opaque with no separable channels. For the rare
91
+ * translucent-semantic need, add a dedicated rgba() variable (see
92
+ * `--destructive-subtle` → `destructive.subtle`) or use `opacity-*` on the element.
93
+ *
94
+ * Safe area: see `addSafeAreaUtilities` for the `*-safe` / `*-safe-<n>` padding
95
+ * utilities that keep content clear of notches and home indicators.
96
+ */
97
+ export const blotchPlugin = plugin(
98
+ ({ addBase, addUtilities, matchUtilities, theme }) => {
99
+ addBase({
100
+ '*': {
101
+ 'border-color': 'var(--border)',
102
+ },
103
+ });
104
+ addSafeAreaUtilities({ addUtilities, matchUtilities, theme });
105
+ },
106
+ {
107
+ theme: {
108
+ extend: {
109
+ colors: {
110
+ background: 'var(--background)',
111
+ foreground: 'var(--foreground)',
112
+ primary: {
113
+ DEFAULT: 'var(--primary)',
114
+ foreground: 'var(--primary-foreground)',
115
+ },
116
+ secondary: {
117
+ DEFAULT: 'var(--secondary)',
118
+ foreground: 'var(--secondary-foreground)',
119
+ },
120
+ muted: {
121
+ DEFAULT: 'var(--muted)',
122
+ foreground: 'var(--muted-foreground)',
123
+ },
124
+ accent: {
125
+ DEFAULT: 'var(--accent)',
126
+ foreground: 'var(--accent-foreground)',
127
+ },
128
+ destructive: {
129
+ DEFAULT: 'var(--destructive)',
130
+ foreground: 'var(--destructive-foreground)',
131
+ // Pre-composed translucent red for subtle destructive backgrounds,
132
+ // since bg-destructive/10 (opacity modifier) can't work here.
133
+ subtle: 'var(--destructive-subtle)',
134
+ },
135
+ border: 'var(--border)',
136
+ input: 'var(--input)',
137
+ ring: 'var(--ring)',
138
+ card: {
139
+ DEFAULT: 'var(--card)',
140
+ foreground: 'var(--card-foreground)',
141
+ },
142
+ },
143
+ borderRadius: {
144
+ lg: 'var(--radius)',
145
+ md: 'calc(var(--radius) - 2px)',
146
+ sm: 'calc(var(--radius) - 4px)',
147
+ },
148
+ },
149
+ },
150
+ },
151
+ );
@@ -0,0 +1,324 @@
1
+ // Exercises every helper in animate.ts against a mock element with a spy on
2
+ // `.animate()`/.setStyle(). The Lynx animate() API doesn't run in jsdom
3
+ // (nothing renders), but the helpers still need to be called so their keyframe
4
+ // / options wiring is covered. Assert on the arguments handed to the mock —
5
+ // each helper's keyframe shape and duration/easing defaults are the meaningful
6
+ // behavior to lock in.
7
+ import { describe, expect, it, vi } from 'vitest';
8
+ import {
9
+ DURATION,
10
+ EASING,
11
+ SCALE,
12
+ directionalSlideIn,
13
+ fadeIn,
14
+ fadeOut,
15
+ overlayClose,
16
+ overlayOpen,
17
+ popIn,
18
+ popOut,
19
+ pressDown,
20
+ pressRelease,
21
+ pulse,
22
+ revealIn,
23
+ scaleIn,
24
+ scaleOut,
25
+ shake,
26
+ slideIn,
27
+ slideOut,
28
+ springTranslateX,
29
+ } from './animate';
30
+
31
+ const makeEl = () => ({
32
+ animate: vi.fn(() => ({ cancel: vi.fn() })),
33
+ setStyle: vi.fn(),
34
+ });
35
+
36
+ describe('animate — constants', () => {
37
+ it('exposes DURATION, EASING, SCALE lookup tables', () => {
38
+ expect(DURATION.instant).toBe(100);
39
+ expect(EASING.decelerate).toContain('cubic-bezier');
40
+ expect(SCALE.popFrom).toBeCloseTo(0.6);
41
+ });
42
+ });
43
+
44
+ describe('animate — primitive helpers on null/undefined', () => {
45
+ it('every helper short-circuits when the element is null', () => {
46
+ expect(fadeIn(null)).toBeUndefined();
47
+ expect(fadeOut(null)).toBeUndefined();
48
+ expect(scaleIn(null)).toBeUndefined();
49
+ expect(scaleOut(null)).toBeUndefined();
50
+ expect(slideIn(null, 'up')).toBeUndefined();
51
+ expect(slideOut(null, 'up')).toBeUndefined();
52
+ expect(popIn(null)).toBeUndefined();
53
+ expect(popOut(null)).toBeUndefined();
54
+ expect(pulse(null)).toBeUndefined();
55
+ expect(shake(null)).toBeUndefined();
56
+ expect(springTranslateX(null, 0, 10)).toBeUndefined();
57
+ expect(revealIn(null)).toBeUndefined();
58
+ expect(directionalSlideIn(null, 1)).toBeUndefined();
59
+ // Press helpers: an element without setStyle also short-circuits.
60
+ expect(pressDown(null)).toBeUndefined();
61
+ expect(pressRelease(null)).toBeUndefined();
62
+ expect(pressDown({})).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe('animate — primitive helpers on a real element', () => {
67
+ it('fadeIn animates opacity 0 → 1 with the default duration/easing', () => {
68
+ const el = makeEl();
69
+ fadeIn(el);
70
+ const [keyframes, options] = el.animate.mock.calls[0];
71
+ expect(keyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]);
72
+ expect(options.duration).toBe(DURATION.normal);
73
+ expect(options.easing).toBe(EASING.decelerate);
74
+ expect(options.fill).toBe('forwards');
75
+ });
76
+
77
+ it('fadeIn honors custom duration / easing / fill', () => {
78
+ const el = makeEl();
79
+ fadeIn(el, { duration: 999, easing: 'linear', fill: 'both' });
80
+ const [, options] = el.animate.mock.calls[0];
81
+ expect(options).toEqual({
82
+ duration: 999,
83
+ easing: 'linear',
84
+ fill: 'both',
85
+ });
86
+ });
87
+
88
+ it('fadeOut animates opacity 1 → 0 with the accelerate default easing', () => {
89
+ const el = makeEl();
90
+ fadeOut(el);
91
+ const [keyframes, options] = el.animate.mock.calls[0];
92
+ expect(keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]);
93
+ expect(options.easing).toBe(EASING.accelerate);
94
+ expect(options.duration).toBe(DURATION.fast);
95
+ });
96
+
97
+ it('scaleIn uses SCALE.dialogFrom by default and honors overrides', () => {
98
+ const el = makeEl();
99
+ scaleIn(el);
100
+ const [keyframes] = el.animate.mock.calls[0];
101
+ expect(keyframes[0].transform).toContain(`scale(${SCALE.dialogFrom})`);
102
+
103
+ const el2 = makeEl();
104
+ scaleIn(el2, { fromScale: 0.5, fromY: 20 });
105
+ const [kf2] = el2.animate.mock.calls[0];
106
+ expect(kf2[0].transform).toContain('scale(0.5)');
107
+ expect(kf2[0].transform).toContain('translateY(20px)');
108
+ });
109
+
110
+ it('scaleOut animates towards the dialog exit transform', () => {
111
+ const el = makeEl();
112
+ scaleOut(el);
113
+ const [keyframes] = el.animate.mock.calls[0];
114
+ expect(keyframes[1].transform).toContain(`scale(${SCALE.dialogTo})`);
115
+
116
+ const el2 = makeEl();
117
+ scaleOut(el2, { toScale: 0.7, toY: 40 });
118
+ const [kf2] = el2.animate.mock.calls[0];
119
+ expect(kf2[1].transform).toContain('scale(0.7)');
120
+ expect(kf2[1].transform).toContain('translateY(40px)');
121
+ });
122
+
123
+ it.each([
124
+ ['up', 'translateY(100%)'],
125
+ ['down', 'translateY(-100%)'],
126
+ ['left', 'translateX(-100%)'],
127
+ ['right', 'translateX(100%)'],
128
+ ] as const)('slideIn from %s starts at %s', (direction, expected) => {
129
+ const el = makeEl();
130
+ slideIn(el, direction);
131
+ const [keyframes] = el.animate.mock.calls[0];
132
+ expect(keyframes[0].transform).toBe(expected);
133
+ });
134
+
135
+ it.each([
136
+ ['up', 'translateY(-100%)'],
137
+ ['down', 'translateY(100%)'],
138
+ ['left', 'translateX(-100%)'],
139
+ ['right', 'translateX(100%)'],
140
+ ] as const)('slideOut to %s ends at %s', (direction, expected) => {
141
+ const el = makeEl();
142
+ slideOut(el, direction);
143
+ const [keyframes] = el.animate.mock.calls[0];
144
+ expect(keyframes[1].transform).toBe(expected);
145
+ });
146
+
147
+ it('popIn defaults from SCALE.popFrom and honors fromScale', () => {
148
+ const el = makeEl();
149
+ popIn(el);
150
+ const [keyframes] = el.animate.mock.calls[0];
151
+ expect(keyframes[0].transform).toContain(`scale(${SCALE.popFrom})`);
152
+
153
+ const el2 = makeEl();
154
+ popIn(el2, { fromScale: 0.4 });
155
+ const [kf2] = el2.animate.mock.calls[0];
156
+ expect(kf2[0].transform).toContain('scale(0.4)');
157
+ });
158
+
159
+ it('popOut defaults to 0.8 and honors toScale', () => {
160
+ const el = makeEl();
161
+ popOut(el);
162
+ const [keyframes] = el.animate.mock.calls[0];
163
+ expect(keyframes[1].transform).toContain('scale(0.8)');
164
+
165
+ const el2 = makeEl();
166
+ popOut(el2, { toScale: 0.3 });
167
+ const [kf2] = el2.animate.mock.calls[0];
168
+ expect(kf2[1].transform).toContain('scale(0.3)');
169
+ });
170
+
171
+ it('pulse loops opacity indefinitely with a 1500ms default', () => {
172
+ const el = makeEl();
173
+ pulse(el);
174
+ const [keyframes, options] = el.animate.mock.calls[0];
175
+ expect(keyframes).toEqual([
176
+ { opacity: 1 },
177
+ { opacity: 0.5 },
178
+ { opacity: 1 },
179
+ ]);
180
+ expect(options.duration).toBe(1500);
181
+ expect(options.iterations).toBe(Infinity);
182
+
183
+ const el2 = makeEl();
184
+ pulse(el2, { duration: 400 });
185
+ const [, opts2] = el2.animate.mock.calls[0];
186
+ expect(opts2.duration).toBe(400);
187
+ });
188
+
189
+ it('shake produces a six-keyframe X wobble with forwards fill', () => {
190
+ const el = makeEl();
191
+ shake(el);
192
+ const [keyframes, options] = el.animate.mock.calls[0];
193
+ expect(keyframes).toHaveLength(6);
194
+ expect(keyframes[0].transform).toBe('translateX(0)');
195
+ expect(options.fill).toBe('forwards');
196
+
197
+ const el2 = makeEl();
198
+ shake(el2, { duration: 999, easing: 'linear' });
199
+ const [, opts2] = el2.animate.mock.calls[0];
200
+ expect(opts2.duration).toBe(999);
201
+ expect(opts2.easing).toBe('linear');
202
+ });
203
+
204
+ it('springTranslateX interpolates through the midpoint with a squish', () => {
205
+ const el = makeEl();
206
+ springTranslateX(el, 0, 20);
207
+ const [keyframes] = el.animate.mock.calls[0];
208
+ expect(keyframes[0].transform).toContain('translateX(0px)');
209
+ expect(keyframes[1].transform).toContain('translateX(10px)');
210
+ expect(keyframes[1].transform).toContain('scale(1.1)');
211
+ expect(keyframes[2].transform).toContain('translateX(20px)');
212
+
213
+ const el2 = makeEl();
214
+ springTranslateX(el2, 0, 20, {
215
+ duration: 500,
216
+ easing: 'ease',
217
+ fill: 'none',
218
+ });
219
+ const [, opts2] = el2.animate.mock.calls[0];
220
+ expect(opts2).toEqual({ duration: 500, easing: 'ease', fill: 'none' });
221
+ });
222
+
223
+ it('revealIn slides in from a small Y offset', () => {
224
+ const el = makeEl();
225
+ revealIn(el);
226
+ const [keyframes] = el.animate.mock.calls[0];
227
+ expect(keyframes[0].transform).toContain('translateY(8px)');
228
+
229
+ const el2 = makeEl();
230
+ revealIn(el2, { fromY: 24 });
231
+ const [kf2] = el2.animate.mock.calls[0];
232
+ expect(kf2[0].transform).toContain('translateY(24px)');
233
+ });
234
+
235
+ it('directionalSlideIn respects the direction sign', () => {
236
+ const el = makeEl();
237
+ directionalSlideIn(el, 1);
238
+ const [keyframes] = el.animate.mock.calls[0];
239
+ expect(keyframes[0].transform).toContain('translateX(24px)');
240
+
241
+ const el2 = makeEl();
242
+ directionalSlideIn(el2, -1);
243
+ const [kf2] = el2.animate.mock.calls[0];
244
+ expect(kf2[0].transform).toContain('translateX(-24px)');
245
+
246
+ const el3 = makeEl();
247
+ directionalSlideIn(el3, 1, { distance: 100 });
248
+ const [kf3] = el3.animate.mock.calls[0];
249
+ expect(kf3[0].transform).toContain('translateX(100px)');
250
+ });
251
+ });
252
+
253
+ describe('animate — press helpers', () => {
254
+ it('pressDown writes a transition + scale via setStyle', () => {
255
+ const el = makeEl();
256
+ const handle = pressDown(el);
257
+ expect(el.setStyle).toHaveBeenCalledWith(
258
+ 'transition',
259
+ expect.stringContaining(`transform ${DURATION.instant}ms`),
260
+ );
261
+ expect(el.setStyle).toHaveBeenCalledWith(
262
+ 'transform',
263
+ `scale(${SCALE.pressDown})`,
264
+ );
265
+ // The returned handle exposes a no-op cancel() for API parity.
266
+ expect(handle).toBeDefined();
267
+ expect(() => handle!.cancel()).not.toThrow();
268
+ });
269
+
270
+ it('pressDown honors a custom scale', () => {
271
+ const el = makeEl();
272
+ pressDown(el, 0.5);
273
+ expect(el.setStyle).toHaveBeenCalledWith('transform', 'scale(0.5)');
274
+ });
275
+
276
+ it('pressRelease returns to scale(1) via a spring easing transition', () => {
277
+ const el = makeEl();
278
+ pressRelease(el);
279
+ expect(el.setStyle).toHaveBeenCalledWith('transform', 'scale(1)');
280
+ expect(el.setStyle).toHaveBeenCalledWith(
281
+ 'transition',
282
+ expect.stringContaining(EASING.spring),
283
+ );
284
+ });
285
+ });
286
+
287
+ describe('animate — composite overlay helpers', () => {
288
+ it.each(['center', 'bottom', 'left', 'right'] as const)(
289
+ 'overlayOpen(%s) fades the backdrop and staggers the panel entrance',
290
+ (type) => {
291
+ vi.useFakeTimers();
292
+ const backdrop = makeEl();
293
+ const panel = makeEl();
294
+
295
+ overlayOpen(backdrop, panel, type);
296
+
297
+ // Backdrop fades in immediately.
298
+ expect(backdrop.animate).toHaveBeenCalled();
299
+ // Panel is scheduled 30ms later — advance the timer to fire it.
300
+ vi.advanceTimersByTime(30);
301
+ expect(panel.animate).toHaveBeenCalled();
302
+ vi.useRealTimers();
303
+ },
304
+ );
305
+
306
+ it.each([
307
+ ['center', 180],
308
+ ['bottom', DURATION.normal],
309
+ ['left', DURATION.normal],
310
+ ['right', DURATION.normal],
311
+ ] as const)(
312
+ 'overlayClose(%s) returns the correct cleanup duration and animates both nodes',
313
+ (type, expectedDuration) => {
314
+ const backdrop = makeEl();
315
+ const panel = makeEl();
316
+
317
+ const duration = overlayClose(backdrop, panel, type);
318
+
319
+ expect(duration).toBe(expectedDuration);
320
+ expect(panel.animate).toHaveBeenCalled();
321
+ expect(backdrop.animate).toHaveBeenCalled();
322
+ },
323
+ );
324
+ });