capture-flow 0.0.0-stage → 0.1.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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +55 -4
  5. package/src/build.ts +186 -0
  6. package/src/capture.ts +77 -0
  7. package/src/cli.ts +139 -0
  8. package/src/index.ts +11 -0
  9. package/src/manifest.ts +98 -0
  10. package/src/render.ts +75 -0
  11. package/src/verify.ts +90 -0
  12. package/vendor/wajha/LICENSE +21 -0
  13. package/vendor/wajha/NOTICE +21 -0
  14. package/vendor/wajha/engine/background.ts +55 -0
  15. package/vendor/wajha/engine/bidi.ts +589 -0
  16. package/vendor/wajha/engine/cache.ts +96 -0
  17. package/vendor/wajha/engine/color.ts +125 -0
  18. package/vendor/wajha/engine/devices/android.ts +53 -0
  19. package/vendor/wajha/engine/devices/browser.ts +170 -0
  20. package/vendor/wajha/engine/devices/finish.ts +154 -0
  21. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  22. package/vendor/wajha/engine/devices/index.ts +143 -0
  23. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  24. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  25. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  26. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  27. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  28. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  29. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  31. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  32. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  33. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  34. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  35. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  36. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  37. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  38. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  39. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  40. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  41. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  42. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  43. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  44. package/vendor/wajha/engine/devices/paths.ts +113 -0
  45. package/vendor/wajha/engine/devices/screen.ts +75 -0
  46. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  47. package/vendor/wajha/engine/devices/slab.ts +200 -0
  48. package/vendor/wajha/engine/devices/vision.ts +43 -0
  49. package/vendor/wajha/engine/devices/watch.ts +14 -0
  50. package/vendor/wajha/engine/geometry.ts +152 -0
  51. package/vendor/wajha/engine/images.ts +96 -0
  52. package/vendor/wajha/engine/index.ts +20 -0
  53. package/vendor/wajha/engine/linebreak.ts +226 -0
  54. package/vendor/wajha/engine/markup.ts +84 -0
  55. package/vendor/wajha/engine/mock.ts +158 -0
  56. package/vendor/wajha/engine/paint.ts +474 -0
  57. package/vendor/wajha/engine/patterns.ts +263 -0
  58. package/vendor/wajha/engine/render.ts +364 -0
  59. package/vendor/wajha/engine/shapes.ts +260 -0
  60. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  61. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  62. package/vendor/wajha/engine/text.ts +694 -0
  63. package/vendor/wajha/engine/types.ts +190 -0
  64. package/vendor/wajha/export/paths.ts +197 -0
  65. package/vendor/wajha/export/png.ts +223 -0
  66. package/vendor/wajha/export/types.ts +60 -0
  67. package/vendor/wajha/model/adapt.ts +889 -0
  68. package/vendor/wajha/model/catalog.ts +369 -0
  69. package/vendor/wajha/model/clone.ts +58 -0
  70. package/vendor/wajha/model/colorMath.ts +128 -0
  71. package/vendor/wajha/model/defaults.ts +386 -0
  72. package/vendor/wajha/model/geometry.ts +95 -0
  73. package/vendor/wajha/model/ids.ts +13 -0
  74. package/vendor/wajha/model/instantiate.ts +280 -0
  75. package/vendor/wajha/model/mirror.ts +43 -0
  76. package/vendor/wajha/model/resolve.ts +87 -0
  77. package/vendor/wajha/model/rules.ts +249 -0
  78. package/vendor/wajha/model/stub.ts +4 -0
  79. package/vendor/wajha/model/types.ts +456 -0
  80. package/vendor/wajha/model/typography.ts +28 -0
  81. package/vendor/wajha/model/validate.ts +917 -0
  82. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  83. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  84. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  85. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  86. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  87. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  88. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  89. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  90. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  91. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  92. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  93. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  94. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  95. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  96. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  97. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  98. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  99. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  100. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  101. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  102. package/vendor/wajha/templates/index.ts +55 -0
  103. package/vendor/wajha/templates/kit.ts +761 -0
  104. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Static catalogs: device families with their store export sizes, languages
3
+ * with their store locale codes, and the fonts bundled with the app.
4
+ *
5
+ * Sizes and locale codes follow Apple's "Screenshot specifications" and
6
+ * Google Play's "Add preview assets" help pages. Keep them in sync with the
7
+ * stores; `catalog.test.ts` checks every store rule we rely on.
8
+ */
9
+ import type { FamilyId, FamilyInfo, ExportSize, LangCode } from './types';
10
+
11
+ const size = (s: ExportSize): ExportSize => s;
12
+
13
+ /** The landscape version of portrait sizes: the same screens on their side. */
14
+ const turned = (family: FamilyId, sizes: ExportSize[]): ExportSize[] =>
15
+ sizes.map((s) => ({ ...s, id: `${s.id}-land`, family, label: `${s.label} landscape`, width: s.height, height: s.width, folder: `${s.folder} landscape` }));
16
+
17
+ const IPHONE_SIZES: ExportSize[] = [
18
+ size({ id: 'ios-6.9', family: 'iphone', store: 'app-store', label: 'iPhone 6.9″', devices: 'iPhone 17 Pro Max, 16 Pro Max', width: 1320, height: 2868, required: true, default: true, folder: 'iPhone 6.9' }),
19
+ size({ id: 'ios-6.9-b', family: 'iphone', store: 'app-store', label: 'iPhone 6.9″ (1290)', devices: 'iPhone 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max', width: 1290, height: 2796, required: false, default: false, folder: 'iPhone 6.9 (1290)' }),
20
+ size({ id: 'ios-6.5', family: 'iphone', store: 'app-store', label: 'iPhone 6.5″', devices: 'iPhone 14 Plus, 13 Pro Max, 12 Pro Max', width: 1284, height: 2778, required: false, default: false, folder: 'iPhone 6.5' }),
21
+ size({ id: 'ios-6.5-b', family: 'iphone', store: 'app-store', label: 'iPhone 6.5″ (1242)', devices: 'iPhone 11 Pro Max, XS Max', width: 1242, height: 2688, required: false, default: false, folder: 'iPhone 6.5 (1242)' }),
22
+ size({ id: 'ios-6.3', family: 'iphone', store: 'app-store', label: 'iPhone 6.3″', devices: 'iPhone 17 Pro, 17, 16 Pro', width: 1206, height: 2622, required: true, default: true, folder: 'iPhone 6.3' }),
23
+ size({ id: 'ios-6.1', family: 'iphone', store: 'app-store', label: 'iPhone 6.1″', devices: 'iPhone 17e, 16e, 14, 13, 13 Pro, 12, 12 Pro', width: 1170, height: 2532, required: false, default: false, folder: 'iPhone 6.1' }),
24
+ ];
25
+
26
+ const IPAD_SIZES: ExportSize[] = [
27
+ size({ id: 'ipad-13', family: 'ipad', store: 'app-store', label: 'iPad 13″', devices: 'iPad Pro 13″ (M4, M5), iPad Air 13″', width: 2064, height: 2752, required: true, default: true, folder: 'iPad 13' }),
28
+ size({ id: 'ipad-12.9', family: 'ipad', store: 'app-store', label: 'iPad 12.9″', devices: 'iPad Pro 12.9″ (2nd–6th gen)', width: 2048, height: 2732, required: false, default: false, folder: 'iPad 12.9' }),
29
+ size({ id: 'ipad-11', family: 'ipad', store: 'app-store', label: 'iPad 11″', devices: 'iPad Pro 11″ (M4), iPad Air 11″', width: 1668, height: 2420, required: false, default: false, folder: 'iPad 11' }),
30
+ ];
31
+
32
+ const PLAY_PHONE_SIZES: ExportSize[] = [
33
+ size({ id: 'play-phone', family: 'android-phone', store: 'google-play', label: 'Phone 1080 × 1920', devices: 'All Android phones (9:16)', width: 1080, height: 1920, required: true, default: true, folder: 'Phone', supplyFolder: 'phoneScreenshots' }),
34
+ size({ id: 'play-phone-hd', family: 'android-phone', store: 'google-play', label: 'Phone 1440 × 2560', devices: 'All Android phones (9:16, high resolution)', width: 1440, height: 2560, required: false, default: false, folder: 'Phone (1440)', supplyFolder: 'phoneScreenshots' }),
35
+ ];
36
+
37
+ export const FAMILIES: Record<FamilyId, FamilyInfo> = {
38
+ iphone: {
39
+ id: 'iphone',
40
+ label: 'iPhone',
41
+ shortLabel: 'iPhone',
42
+ stores: ['app-store'],
43
+ design: { width: 1320, height: 2868 },
44
+ aspectClass: 'tall',
45
+ defaultFrame: 'iphone',
46
+ screenAspect: 2868 / 1320,
47
+ maxSlides: 10,
48
+ sizes: IPHONE_SIZES,
49
+ },
50
+ 'iphone-landscape': {
51
+ id: 'iphone-landscape',
52
+ label: 'iPhone landscape',
53
+ shortLabel: 'iPhone ↔',
54
+ stores: ['app-store'],
55
+ design: { width: 2868, height: 1320 },
56
+ aspectClass: 'wide',
57
+ defaultFrame: 'iphone',
58
+ screenAspect: 1320 / 2868,
59
+ maxSlides: 10,
60
+ landscape: true,
61
+ sizes: turned('iphone-landscape', IPHONE_SIZES),
62
+ },
63
+ ipad: {
64
+ id: 'ipad',
65
+ label: 'iPad',
66
+ shortLabel: 'iPad',
67
+ stores: ['app-store'],
68
+ design: { width: 2064, height: 2752 },
69
+ aspectClass: 'tablet',
70
+ defaultFrame: 'ipad',
71
+ screenAspect: 2752 / 2064,
72
+ maxSlides: 10,
73
+ sizes: IPAD_SIZES,
74
+ },
75
+ 'ipad-landscape': {
76
+ id: 'ipad-landscape',
77
+ label: 'iPad landscape',
78
+ shortLabel: 'iPad ↔',
79
+ stores: ['app-store'],
80
+ design: { width: 2752, height: 2064 },
81
+ aspectClass: 'wide',
82
+ defaultFrame: 'ipad',
83
+ screenAspect: 2064 / 2752,
84
+ maxSlides: 10,
85
+ landscape: true,
86
+ sizes: turned('ipad-landscape', IPAD_SIZES),
87
+ },
88
+ 'android-phone': {
89
+ id: 'android-phone',
90
+ label: 'Android phone',
91
+ shortLabel: 'Android',
92
+ stores: ['google-play'],
93
+ design: { width: 1080, height: 1920 },
94
+ aspectClass: 'tall',
95
+ defaultFrame: 'android-phone',
96
+ screenAspect: 2400 / 1080,
97
+ maxSlides: 8,
98
+ sizes: PLAY_PHONE_SIZES,
99
+ },
100
+ 'android-landscape': {
101
+ id: 'android-landscape',
102
+ label: 'Android phone landscape',
103
+ shortLabel: 'Android ↔',
104
+ stores: ['google-play'],
105
+ design: { width: 1920, height: 1080 },
106
+ aspectClass: 'wide',
107
+ defaultFrame: 'android-phone',
108
+ screenAspect: 1080 / 2400,
109
+ maxSlides: 8,
110
+ landscape: true,
111
+ sizes: turned('android-landscape', PLAY_PHONE_SIZES),
112
+ },
113
+ 'android-tablet': {
114
+ id: 'android-tablet',
115
+ label: 'Android tablet',
116
+ shortLabel: 'Tablet',
117
+ stores: ['google-play'],
118
+ design: { width: 1440, height: 2560 },
119
+ aspectClass: 'tablet',
120
+ defaultFrame: 'android-tablet',
121
+ screenAspect: 2560 / 1600,
122
+ maxSlides: 8,
123
+ sizes: [
124
+ size({ id: 'play-tablet-7', family: 'android-tablet', store: 'google-play', label: '7″ tablet', devices: '7-inch Android tablets (9:16)', width: 1080, height: 1920, required: false, default: true, folder: 'Tablet 7', supplyFolder: 'sevenInchScreenshots' }),
125
+ size({ id: 'play-tablet-10', family: 'android-tablet', store: 'google-play', label: '10″ tablet', devices: '10-inch Android tablets (9:16)', width: 1440, height: 2560, required: false, default: true, folder: 'Tablet 10', supplyFolder: 'tenInchScreenshots' }),
126
+ ],
127
+ },
128
+ mac: {
129
+ id: 'mac',
130
+ label: 'Mac',
131
+ shortLabel: 'Mac',
132
+ stores: ['app-store'],
133
+ design: { width: 2880, height: 1800 },
134
+ aspectClass: 'wide',
135
+ defaultFrame: 'macbook',
136
+ screenAspect: 1800 / 2880,
137
+ maxSlides: 10,
138
+ sizes: [
139
+ size({ id: 'mac-2880', family: 'mac', store: 'app-store', label: 'Mac 2880 × 1800', devices: 'Mac (Retina 16:10)', width: 2880, height: 1800, required: true, default: true, folder: 'Mac 2880' }),
140
+ size({ id: 'mac-2560', family: 'mac', store: 'app-store', label: 'Mac 2560 × 1600', devices: 'Mac (Retina 16:10)', width: 2560, height: 1600, required: false, default: false, folder: 'Mac 2560' }),
141
+ size({ id: 'mac-1440', family: 'mac', store: 'app-store', label: 'Mac 1440 × 900', devices: 'Mac (16:10)', width: 1440, height: 900, required: false, default: false, folder: 'Mac 1440' }),
142
+ size({ id: 'mac-1280', family: 'mac', store: 'app-store', label: 'Mac 1280 × 800', devices: 'Mac (16:10)', width: 1280, height: 800, required: false, default: false, folder: 'Mac 1280' }),
143
+ ],
144
+ },
145
+ watch: {
146
+ id: 'watch',
147
+ label: 'Apple Watch',
148
+ shortLabel: 'Watch',
149
+ stores: ['app-store'],
150
+ design: { width: 422, height: 514 },
151
+ aspectClass: 'tablet',
152
+ defaultFrame: 'watch',
153
+ screenAspect: 514 / 422,
154
+ maxSlides: 10,
155
+ sizes: [
156
+ size({ id: 'watch-ultra-3', family: 'watch', store: 'app-store', label: 'Apple Watch Ultra 3', devices: 'Apple Watch Ultra 3', width: 422, height: 514, required: true, default: true, folder: 'Apple Watch Ultra 3' }),
157
+ size({ id: 'watch-11', family: 'watch', store: 'app-store', label: 'Apple Watch Series 11', devices: 'Apple Watch Series 11, 10', width: 416, height: 496, required: false, default: false, folder: 'Apple Watch Series 11' }),
158
+ size({ id: 'watch-ultra-2', family: 'watch', store: 'app-store', label: 'Apple Watch Ultra 2', devices: 'Apple Watch Ultra 2, Ultra', width: 410, height: 502, required: false, default: false, folder: 'Apple Watch Ultra 2' }),
159
+ size({ id: 'watch-9', family: 'watch', store: 'app-store', label: 'Apple Watch Series 9', devices: 'Apple Watch Series 9, 8, 7', width: 396, height: 484, required: false, default: false, folder: 'Apple Watch Series 9' }),
160
+ size({ id: 'watch-6', family: 'watch', store: 'app-store', label: 'Apple Watch Series 6', devices: 'Apple Watch Series 6, 5, 4, SE', width: 368, height: 448, required: false, default: false, folder: 'Apple Watch Series 6' }),
161
+ size({ id: 'watch-3', family: 'watch', store: 'app-store', label: 'Apple Watch Series 3', devices: 'Apple Watch Series 3', width: 312, height: 390, required: false, default: false, folder: 'Apple Watch Series 3' }),
162
+ ],
163
+ },
164
+ 'apple-tv': {
165
+ id: 'apple-tv',
166
+ label: 'Apple TV',
167
+ shortLabel: 'TV',
168
+ stores: ['app-store'],
169
+ design: { width: 3840, height: 2160 },
170
+ aspectClass: 'wide',
171
+ defaultFrame: 'tv',
172
+ screenAspect: 2160 / 3840,
173
+ maxSlides: 10,
174
+ sizes: [
175
+ size({ id: 'tv-4k', family: 'apple-tv', store: 'app-store', label: 'Apple TV 3840 × 2160', devices: 'Apple TV 4K', width: 3840, height: 2160, required: true, default: true, folder: 'Apple TV 4K' }),
176
+ size({ id: 'tv-hd', family: 'apple-tv', store: 'app-store', label: 'Apple TV 1920 × 1080', devices: 'Apple TV HD', width: 1920, height: 1080, required: false, default: false, folder: 'Apple TV' }),
177
+ ],
178
+ },
179
+ vision: {
180
+ id: 'vision',
181
+ label: 'Apple Vision Pro',
182
+ shortLabel: 'Vision',
183
+ stores: ['app-store'],
184
+ design: { width: 3840, height: 2160 },
185
+ aspectClass: 'wide',
186
+ defaultFrame: 'vision',
187
+ screenAspect: 2160 / 3840,
188
+ maxSlides: 10,
189
+ sizes: [
190
+ size({ id: 'vision-pro', family: 'vision', store: 'app-store', label: 'Apple Vision Pro 3840 × 2160', devices: 'Apple Vision Pro', width: 3840, height: 2160, required: true, default: true, folder: 'Apple Vision Pro' }),
191
+ ],
192
+ },
193
+ 'play-feature': {
194
+ id: 'play-feature',
195
+ label: 'Play feature graphic',
196
+ shortLabel: 'Feature',
197
+ stores: ['google-play'],
198
+ design: { width: 1024, height: 500 },
199
+ aspectClass: 'wide',
200
+ defaultFrame: 'android-phone',
201
+ screenAspect: 2400 / 1080,
202
+ maxSlides: 1,
203
+ sizes: [
204
+ size({ id: 'play-feature', family: 'play-feature', store: 'google-play', label: 'Feature graphic 1024 × 500', devices: 'Google Play store listing banner', width: 1024, height: 500, required: true, default: true, folder: 'Feature graphic', supplyFolder: 'featureGraphic' }),
205
+ ],
206
+ },
207
+ };
208
+
209
+ export const FAMILY_ORDER: FamilyId[] = [
210
+ 'iphone',
211
+ 'iphone-landscape',
212
+ 'ipad',
213
+ 'ipad-landscape',
214
+ 'mac',
215
+ 'watch',
216
+ 'apple-tv',
217
+ 'vision',
218
+ 'android-phone',
219
+ 'android-landscape',
220
+ 'android-tablet',
221
+ 'play-feature',
222
+ ];
223
+
224
+ export const ALL_SIZES: ExportSize[] = FAMILY_ORDER.flatMap((f) => FAMILIES[f].sizes);
225
+
226
+ export function sizeById(id: string): ExportSize | undefined {
227
+ return ALL_SIZES.find((s) => s.id === id);
228
+ }
229
+
230
+ /* ------------------------------------------------------------------ */
231
+ /* Languages */
232
+ /* ------------------------------------------------------------------ */
233
+
234
+ export interface LanguageInfo {
235
+ /** Canonical code used inside projects. */
236
+ code: LangCode;
237
+ /** English name */
238
+ name: string;
239
+ /** Name in the language itself */
240
+ native: string;
241
+ rtl: boolean;
242
+ /** App Store Connect locale code, or null when the App Store has none. */
243
+ appStore: string | null;
244
+ /** Google Play listing locale code, or null when Google Play has none. */
245
+ play: string | null;
246
+ /** Word-break strategy for text layout. */
247
+ wrap: 'space' | 'char';
248
+ }
249
+
250
+ const L = (code: string, name: string, native: string, appStore: string | null, play: string | null, rtl = false, wrap: 'space' | 'char' = 'space'): LanguageInfo => ({
251
+ code,
252
+ name,
253
+ native,
254
+ rtl,
255
+ appStore,
256
+ play,
257
+ wrap,
258
+ });
259
+
260
+ export const LANGUAGES: LanguageInfo[] = [
261
+ L('en-US', 'English (US)', 'English (US)', 'en-US', 'en-US'),
262
+ L('en-GB', 'English (UK)', 'English (UK)', 'en-GB', 'en-GB'),
263
+ L('en-AU', 'English (Australia)', 'English (Australia)', 'en-AU', 'en-AU'),
264
+ L('en-CA', 'English (Canada)', 'English (Canada)', 'en-CA', 'en-CA'),
265
+ L('ar-SA', 'Arabic', 'العربية', 'ar-SA', 'ar', true),
266
+ L('bn-BD', 'Bangla', 'বাংলা', 'bn-BD', 'bn-BD'),
267
+ L('ca', 'Catalan', 'Català', 'ca', 'ca'),
268
+ L('zh-Hans', 'Chinese (Simplified)', '简体中文', 'zh-Hans', 'zh-CN', false, 'char'),
269
+ L('zh-Hant', 'Chinese (Traditional)', '繁體中文', 'zh-Hant', 'zh-TW', false, 'char'),
270
+ L('hr', 'Croatian', 'Hrvatski', 'hr', 'hr'),
271
+ L('cs', 'Czech', 'Čeština', 'cs', 'cs-CZ'),
272
+ L('da', 'Danish', 'Dansk', 'da', 'da-DK'),
273
+ L('nl-NL', 'Dutch', 'Nederlands', 'nl-NL', 'nl-NL'),
274
+ L('fi', 'Finnish', 'Suomi', 'fi', 'fi-FI'),
275
+ L('fr-FR', 'French', 'Français', 'fr-FR', 'fr-FR'),
276
+ L('fr-CA', 'French (Canada)', 'Français (Canada)', 'fr-CA', 'fr-CA'),
277
+ L('de-DE', 'German', 'Deutsch', 'de-DE', 'de-DE'),
278
+ L('el', 'Greek', 'Ελληνικά', 'el', 'el-GR'),
279
+ L('gu-IN', 'Gujarati', 'ગુજરાતી', 'gu-IN', 'gu'),
280
+ L('he', 'Hebrew', 'עברית', 'he', 'iw-IL', true),
281
+ L('hi', 'Hindi', 'हिन्दी', 'hi', 'hi-IN'),
282
+ L('hu', 'Hungarian', 'Magyar', 'hu', 'hu-HU'),
283
+ L('id', 'Indonesian', 'Bahasa Indonesia', 'id', 'id'),
284
+ L('it', 'Italian', 'Italiano', 'it', 'it-IT'),
285
+ L('ja', 'Japanese', '日本語', 'ja', 'ja-JP', false, 'char'),
286
+ L('kn-IN', 'Kannada', 'ಕನ್ನಡ', 'kn-IN', 'kn-IN'),
287
+ L('ko', 'Korean', '한국어', 'ko', 'ko-KR'),
288
+ L('ms', 'Malay', 'Bahasa Melayu', 'ms', 'ms'),
289
+ L('ml-IN', 'Malayalam', 'മലയാളം', 'ml-IN', 'ml-IN'),
290
+ L('mr-IN', 'Marathi', 'मराठी', 'mr-IN', 'mr-IN'),
291
+ L('no', 'Norwegian', 'Norsk', 'no', 'no-NO'),
292
+ L('or-IN', 'Odia', 'ଓଡ଼ିଆ', 'or-IN', null),
293
+ L('fa', 'Persian', 'فارسی', null, 'fa', true),
294
+ L('pl', 'Polish', 'Polski', 'pl', 'pl-PL'),
295
+ L('pt-BR', 'Portuguese (Brazil)', 'Português (Brasil)', 'pt-BR', 'pt-BR'),
296
+ L('pt-PT', 'Portuguese (Portugal)', 'Português (Portugal)', 'pt-PT', 'pt-PT'),
297
+ L('pa-IN', 'Punjabi', 'ਪੰਜਾਬੀ', 'pa-IN', 'pa'),
298
+ L('ro', 'Romanian', 'Română', 'ro', 'ro'),
299
+ L('ru', 'Russian', 'Русский', 'ru', 'ru-RU'),
300
+ L('sk', 'Slovak', 'Slovenčina', 'sk', 'sk'),
301
+ L('sl-SI', 'Slovenian', 'Slovenščina', 'sl-SI', 'sl'),
302
+ L('es-ES', 'Spanish (Spain)', 'Español (España)', 'es-ES', 'es-ES'),
303
+ L('es-MX', 'Spanish (Mexico)', 'Español (México)', 'es-MX', 'es-419'),
304
+ L('sv', 'Swedish', 'Svenska', 'sv', 'sv-SE'),
305
+ L('ta-IN', 'Tamil', 'தமிழ்', 'ta-IN', 'ta-IN'),
306
+ L('te-IN', 'Telugu', 'తెలుగు', 'te-IN', 'te-IN'),
307
+ L('th', 'Thai', 'ไทย', 'th', 'th', false, 'char'),
308
+ L('tr', 'Turkish', 'Türkçe', 'tr', 'tr-TR'),
309
+ L('uk', 'Ukrainian', 'Українська', 'uk', 'uk'),
310
+ L('ur-PK', 'Urdu', 'اردو', 'ur-PK', 'ur', true),
311
+ L('vi', 'Vietnamese', 'Tiếng Việt', 'vi', 'vi'),
312
+ ];
313
+
314
+ export function languageInfo(code: LangCode): LanguageInfo {
315
+ return (
316
+ LANGUAGES.find((l) => l.code === code) ?? {
317
+ code,
318
+ name: code,
319
+ native: code,
320
+ rtl: false,
321
+ appStore: code,
322
+ play: code,
323
+ wrap: 'space',
324
+ }
325
+ );
326
+ }
327
+
328
+ export function isRtl(code: LangCode): boolean {
329
+ return languageInfo(code).rtl;
330
+ }
331
+
332
+ /* ------------------------------------------------------------------ */
333
+ /* Bundled fonts */
334
+ /* ------------------------------------------------------------------ */
335
+
336
+ export interface FontInfo {
337
+ /** CSS family name exactly as registered by @fontsource-variable. */
338
+ family: string;
339
+ label: string;
340
+ category: 'sans' | 'serif' | 'display' | 'arabic' | 'multi';
341
+ /** Variable weight range. */
342
+ weights: [number, number];
343
+ /** Scripts the font covers beyond Latin. */
344
+ scripts: string[];
345
+ }
346
+
347
+ export const BUNDLED_FONTS: FontInfo[] = [
348
+ { family: 'Inter Variable', label: 'Inter', category: 'sans', weights: [100, 900], scripts: ['latin', 'cyrillic', 'greek', 'vietnamese'] },
349
+ { family: 'Plus Jakarta Sans Variable', label: 'Plus Jakarta Sans', category: 'sans', weights: [200, 800], scripts: ['latin', 'vietnamese'] },
350
+ { family: 'Manrope Variable', label: 'Manrope', category: 'sans', weights: [200, 800], scripts: ['latin', 'cyrillic', 'greek'] },
351
+ { family: 'DM Sans Variable', label: 'DM Sans', category: 'sans', weights: [100, 1000], scripts: ['latin'] },
352
+ { family: 'Outfit Variable', label: 'Outfit', category: 'sans', weights: [100, 900], scripts: ['latin'] },
353
+ { family: 'Sora Variable', label: 'Sora', category: 'sans', weights: [100, 800], scripts: ['latin'] },
354
+ { family: 'Space Grotesk Variable', label: 'Space Grotesk', category: 'display', weights: [300, 700], scripts: ['latin', 'vietnamese'] },
355
+ { family: 'Bricolage Grotesque Variable', label: 'Bricolage Grotesque', category: 'display', weights: [200, 800], scripts: ['latin', 'vietnamese'] },
356
+ { family: 'Fraunces Variable', label: 'Fraunces', category: 'serif', weights: [100, 900], scripts: ['latin', 'vietnamese'] },
357
+ { family: 'Playfair Display Variable', label: 'Playfair Display', category: 'serif', weights: [400, 900], scripts: ['latin', 'cyrillic', 'vietnamese'] },
358
+ { family: 'Rubik Variable', label: 'Rubik', category: 'multi', weights: [300, 900], scripts: ['latin', 'cyrillic', 'hebrew'] },
359
+ { family: 'Cairo Variable', label: 'Cairo', category: 'arabic', weights: [200, 1000], scripts: ['latin', 'arabic'] },
360
+ { family: 'Readex Pro Variable', label: 'Readex Pro', category: 'arabic', weights: [160, 700], scripts: ['latin', 'arabic', 'vietnamese'] },
361
+ { family: 'Noto Sans Arabic Variable', label: 'Noto Sans Arabic', category: 'arabic', weights: [100, 900], scripts: ['arabic'] },
362
+ ];
363
+
364
+ /**
365
+ * Appended to every font in canvas text so Arabic, Hebrew, CJK and other
366
+ * scripts always find glyphs, even when the chosen font lacks them.
367
+ */
368
+ export const FONT_FALLBACK_STACK =
369
+ '"Noto Sans Arabic Variable", "Rubik Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans", "Noto Sans CJK SC", "Microsoft YaHei", system-ui, sans-serif';
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Copying and comparing plain JSON data (the whole model is JSON).
3
+ *
4
+ * Copies matter because the store keeps immer-frozen snapshots: an object
5
+ * taken from one (or from the resolveLayout cache) must be copied before it
6
+ * is placed into a draft and edited, or the edit throws in strict mode.
7
+ * `structuredClone` is avoided on purpose: older WKWebView builds lack it.
8
+ */
9
+
10
+ /** Deep copy of JSON-like data. Unsafe keys (`__proto__`, ...) are skipped. */
11
+ export function deepClone<T>(value: T): T {
12
+ return cloneValue(value) as T;
13
+ }
14
+
15
+ function cloneValue(v: unknown): unknown {
16
+ if (v === null || typeof v !== 'object') return v;
17
+ if (Array.isArray(v)) {
18
+ const out: unknown[] = new Array(v.length);
19
+ for (let i = 0; i < v.length; i++) out[i] = cloneValue(v[i]);
20
+ return out;
21
+ }
22
+ const out: Record<string, unknown> = {};
23
+ for (const key of Object.keys(v)) {
24
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
25
+ out[key] = cloneValue((v as Record<string, unknown>)[key]);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /**
31
+ * Structural equality for JSON-like data. Keys whose value is `undefined`
32
+ * count as absent, matching what JSON.stringify would write.
33
+ */
34
+ export function jsonEqual(a: unknown, b: unknown): boolean {
35
+ if (a === b) return true;
36
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') {
37
+ // NaN never equals itself through ===; treat two NaNs as equal data.
38
+ return typeof a === 'number' && typeof b === 'number' && Number.isNaN(a) && Number.isNaN(b);
39
+ }
40
+ const aArr = Array.isArray(a);
41
+ if (aArr !== Array.isArray(b)) return false;
42
+ if (aArr) {
43
+ const x = a as unknown[];
44
+ const y = b as unknown[];
45
+ if (x.length !== y.length) return false;
46
+ for (let i = 0; i < x.length; i++) if (!jsonEqual(x[i], y[i])) return false;
47
+ return true;
48
+ }
49
+ const x = a as Record<string, unknown>;
50
+ const y = b as Record<string, unknown>;
51
+ const xk = Object.keys(x).filter((k) => x[k] !== undefined);
52
+ const yk = Object.keys(y).filter((k) => y[k] !== undefined);
53
+ if (xk.length !== yk.length) return false;
54
+ for (const k of xk) {
55
+ if (!Object.prototype.hasOwnProperty.call(y, k) || !jsonEqual(x[k], y[k])) return false;
56
+ }
57
+ return true;
58
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Just enough colour math for the model to pick readable defaults (text on
3
+ * a theme background, highlight colours). The engine has its own helpers;
4
+ * the model stays free of engine imports.
5
+ */
6
+ import type { Background, Color, Fill } from './types';
7
+
8
+ interface Rgb {
9
+ r: number;
10
+ g: number;
11
+ b: number;
12
+ }
13
+
14
+ /** Parses #rgb, #rrggbb and #rrggbbaa (alpha ignored). Anything else is mid grey. */
15
+ function rgb(hex: string): Rgb {
16
+ const s = typeof hex === 'string' ? hex.replace(/^#/, '') : '';
17
+ const full = /^[0-9a-fA-F]{3,4}$/.test(s) ? [...s].map((c) => c + c).join('') : s;
18
+ if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(full)) return { r: 128, g: 128, b: 128 };
19
+ return { r: parseInt(full.slice(0, 2), 16), g: parseInt(full.slice(2, 4), 16), b: parseInt(full.slice(4, 6), 16) };
20
+ }
21
+
22
+ const hex2 = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0');
23
+
24
+ function toHex(c: Rgb): string {
25
+ return `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`;
26
+ }
27
+
28
+ /** WCAG relative luminance, 0 (black) .. 1 (white). */
29
+ export function luminance(hex: string): number {
30
+ const { r, g, b } = rgb(hex);
31
+ const lin = (v: number) => {
32
+ const s = v / 255;
33
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
34
+ };
35
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
36
+ }
37
+
38
+ /** WCAG contrast ratio (1..21). */
39
+ export function contrast(a: string, b: string): number {
40
+ const la = luminance(a);
41
+ const lb = luminance(b);
42
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
43
+ }
44
+
45
+ /** sRGB mix of opaque colours; t = 0 gives a, t = 1 gives b. */
46
+ export function mixColors(a: string, b: string, t: number): string {
47
+ const x = rgb(a);
48
+ const y = rgb(b);
49
+ const k = Number.isFinite(t) ? Math.min(1, Math.max(0, t)) : 0;
50
+ return toHex({ r: x.r + (y.r - x.r) * k, g: x.g + (y.g - x.g) * k, b: x.b + (y.b - x.b) * k });
51
+ }
52
+
53
+ /** "#RRGGBB" with an alpha byte appended (alpha 0..1). */
54
+ export function withAlpha(hex: string, alpha: number): string {
55
+ const a = Number.isFinite(alpha) ? Math.min(1, Math.max(0, alpha)) : 1;
56
+ return `${toHex(rgb(hex))}${hex2(a * 255)}`;
57
+ }
58
+
59
+ function average(colors: string[]): string {
60
+ if (colors.length === 0) return '#808080';
61
+ let r = 0;
62
+ let g = 0;
63
+ let b = 0;
64
+ for (const c of colors) {
65
+ const v = rgb(c);
66
+ r += v.r;
67
+ g += v.g;
68
+ b += v.b;
69
+ }
70
+ return toHex({ r: r / colors.length, g: g / colors.length, b: b / colors.length });
71
+ }
72
+
73
+ /**
74
+ * One colour that stands for what a fill looks like on average, used to
75
+ * decide between light and dark text. Mesh blobs cover only part of the
76
+ * canvas, so they count for half against the base.
77
+ */
78
+ export function fillTone(fill: Fill): Color {
79
+ // Defensive about shape: callers may hand in data that was never validated.
80
+ switch (fill?.type) {
81
+ case 'solid':
82
+ return toHex(rgb(fill.color));
83
+ case 'linear':
84
+ case 'radial':
85
+ return average(Array.isArray(fill.stops) ? fill.stops.map((s) => s.color) : []);
86
+ case 'mesh': {
87
+ const points = Array.isArray(fill.points) ? fill.points : [];
88
+ return points.length ? mixColors(fill.base, average(points.map((p) => p.color)), 0.5) : toHex(rgb(fill.base));
89
+ }
90
+ case 'image':
91
+ // Photos behind store copy are usually darkened; a tint decides when present.
92
+ return fill.tint ? mixColors('#303030', fill.tint, 0.5) : '#303030';
93
+ default:
94
+ return '#808080';
95
+ }
96
+ }
97
+
98
+ /** Representative colour of a background (its fill; patterns are too faint to matter). */
99
+ export function backgroundTone(bg: Background): Color {
100
+ return fillTone(bg?.fill);
101
+ }
102
+
103
+ /** True when light text reads better than dark text on `tone`. */
104
+ export function prefersLightText(tone: string): boolean {
105
+ return contrast(tone, '#ffffff') >= contrast(tone, '#14121f');
106
+ }
107
+
108
+ /**
109
+ * The palette colour that pops most against `tone` while staying distinct
110
+ * from the body text colour; `fallback` when none reaches 3:1.
111
+ */
112
+ export function pickAccent(palette: readonly string[], tone: string, text: string, fallback: string): string {
113
+ let best: string | null = null;
114
+ let bestScore = 0;
115
+ for (const c of palette) {
116
+ const vsBg = contrast(c, tone);
117
+ if (vsBg < 3 || contrast(c, text) < 1.4) continue;
118
+ // Prefer saturated accents over near-greys of equal contrast.
119
+ const { r, g, b } = rgb(c);
120
+ const chroma = (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
121
+ const score = vsBg * (0.5 + chroma);
122
+ if (score > bestScore) {
123
+ bestScore = score;
124
+ best = c;
125
+ }
126
+ }
127
+ return best ? toHex(rgb(best)) : fallback;
128
+ }