@felipe-software/react-native-gpui 0.0.0-placeholder → 1.0.1

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 (72) hide show
  1. package/.reanimated-prebuilt/react-native-reanimated.mjs +29373 -0
  2. package/LICENSE +21 -0
  3. package/README.md +658 -1
  4. package/cli/bin.ts +379 -0
  5. package/cli/bundle.ts +32 -0
  6. package/cli/commands/do.ts +213 -0
  7. package/cli/commands/flow.ts +283 -0
  8. package/cli/commands/get.ts +584 -0
  9. package/cli/commands/shot.ts +312 -0
  10. package/cli/commands/trace.ts +293 -0
  11. package/cli/host.ts +714 -0
  12. package/cli/hotReload.ts +210 -0
  13. package/cli/run.ts +66 -0
  14. package/cli/selectors.ts +205 -0
  15. package/dist/Animated.d.ts +120 -0
  16. package/dist/ChatList.d.ts +28 -0
  17. package/dist/Dimensions.d.ts +31 -0
  18. package/dist/StyleSheet.d.ts +18 -0
  19. package/dist/Window.d.ts +33 -0
  20. package/dist/apis.d.ts +165 -0
  21. package/dist/app-state.d.ts +27 -0
  22. package/dist/chat-list-types.d.ts +136 -0
  23. package/dist/colors.d.ts +32 -0
  24. package/dist/commands.d.ts +230 -0
  25. package/dist/components.d.ts +419 -0
  26. package/dist/hotModules.d.ts +1 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.development.js +14876 -0
  29. package/dist/index.js +12 -0
  30. package/dist/keyboard.d.ts +140 -0
  31. package/dist/native-menu.d.ts +7 -0
  32. package/dist/platform-driver.d.ts +32 -0
  33. package/dist/platform-services.d.ts +35 -0
  34. package/dist/portal.d.ts +43 -0
  35. package/dist/raf.d.ts +4 -0
  36. package/dist/reanimated/colors-processor-shim.d.ts +10 -0
  37. package/dist/reanimated/reanimated-host.d.ts +10 -0
  38. package/dist/reanimated/seam.d.ts +34 -0
  39. package/dist/reanimated/ui-entry.d.ts +2 -0
  40. package/dist/reanimated/worklet-channel.d.ts +10 -0
  41. package/dist/reanimated/worklet-runtime.d.ts +466 -0
  42. package/dist/reanimated/worklets.d.ts +199 -0
  43. package/dist/reconciler.d.ts +102 -0
  44. package/dist/refresh.d.ts +1 -0
  45. package/dist/render.d.ts +37 -0
  46. package/dist/runtime.d.ts +235 -0
  47. package/dist/surfaces.d.ts +51 -0
  48. package/dist/types.d.ts +276 -0
  49. package/dist/ui-runtime.js +42276 -0
  50. package/dist/window-context.d.ts +7 -0
  51. package/dist/window-host.d.ts +41 -0
  52. package/dist/wire-delta.d.ts +20 -0
  53. package/package.json +177 -1
  54. package/scripts/bundle-app.mjs +88 -0
  55. package/scripts/conformance-utils.mjs +293 -0
  56. package/scripts/hot-update-alias-plugin.mjs +88 -0
  57. package/scripts/native-package-exports-plugin.mjs +235 -0
  58. package/scripts/pixel.mjs +152 -0
  59. package/scripts/png.mjs +198 -0
  60. package/scripts/react-refresh-swc.mjs +35 -0
  61. package/scripts/reanimated-bun-plugin.mjs +188 -0
  62. package/scripts/service-binary.d.mts +27 -0
  63. package/scripts/service-binary.mjs +100 -0
  64. package/src/reanimated/colors-processor-shim.ts +119 -0
  65. package/src/reanimated/reanimated-host.ts +69 -0
  66. package/src/reanimated/rngpui-reanimated-prebuilt.d.ts +7 -0
  67. package/src/reanimated/seam.ts +566 -0
  68. package/src/reanimated/ui-entry.ts +80 -0
  69. package/src/reanimated/worklet-channel.ts +64 -0
  70. package/src/reanimated/worklet-runtime.ts +2569 -0
  71. package/src/reanimated/worklets.ts +1334 -0
  72. package/src/refresh.ts +33 -0
@@ -0,0 +1,88 @@
1
+ const REACT_EXPORTS = [
2
+ 'Children', 'Component', 'Fragment', 'Profiler', 'PureComponent', 'StrictMode', 'Suspense',
3
+ 'cloneElement', 'createContext', 'createElement', 'createRef', 'forwardRef', 'isValidElement',
4
+ 'lazy', 'memo', 'startTransition', 'use', 'useActionState', 'useCallback', 'useContext',
5
+ 'useDebugValue', 'useDeferredValue', 'useEffect', 'useId', 'useImperativeHandle',
6
+ 'useInsertionEffect', 'useLayoutEffect', 'useMemo', 'useOptimistic', 'useReducer', 'useRef',
7
+ 'useState', 'useSyncExternalStore', 'useTransition', 'version',
8
+ ]
9
+
10
+ const RNGPUI_EXPORTS = [
11
+ 'createRoot', 'render', 'AppRegistry', 'unstable_batchedUpdates',
12
+ 'View', 'Text', 'TextInput', 'Image', 'Svg', 'WebView',
13
+ 'SystemView', 'ScrollView', 'Pressable', 'TouchableOpacity',
14
+ 'RefreshControl', 'Diff',
15
+ 'TouchableHighlight', 'TouchableWithoutFeedback', 'ListGroup', 'Button', 'NativeButton',
16
+ 'NativeTextInput', 'SafeAreaView',
17
+ 'KeyboardAvoidingView', 'Switch', 'FlatList', 'SectionList', 'ActivityIndicator', 'StatusBar',
18
+ 'Modal', 'requireNativeComponent', 'codegenNativeComponent', 'codegenNativeCommands',
19
+ 'StyleSheet', 'normalizeStyle', 'LiquidGlassBackground', 'LiquidGlassView', 'EffectSurface',
20
+ 'SmokeEffectSurface', 'effectBackgroundImage', 'smokeEffectBackgroundImage', 'Dimensions',
21
+ 'useWindowDimensions', 'Platform', 'PixelRatio', 'useColorScheme', 'Appearance',
22
+ 'DynamicColorIOS', 'PlatformColor', 'I18nManager', 'Alert', 'Keyboard', 'BackHandler',
23
+ 'AppState', 'Linking', 'InteractionManager', 'LayoutAnimation', 'UIManager', 'NativeModules',
24
+ 'findNodeHandle', 'processColor', 'resolveColorValue', 'PanResponder', 'Vibration',
25
+ 'DeviceEventEmitter', 'NativeEventEmitter', 'AccessibilityInfo', 'FilePicker', 'VoiceRecorder',
26
+ 'Animated', 'AnimatedValue', 'Easing', 'AppCommands', 'Dock', 'NativeClipboard', 'NativeLayout',
27
+ 'NativeMenus', 'NativeWindow', 'PerformanceHUD', 'Renderer', 'setupTamaguiNativeMenus',
28
+ 'KeyboardNavigationProvider', 'useKeyboardNavigation', 'useKeyboardNavigationController',
29
+ 'useKeyboardNavigationState', 'useKeyboardNavigationTarget', 'useKeyboardNavigationKeyPress',
30
+ 'useKeyboardNavigationWindowKeyboard', 'mergeRefs', 'enabledKeyboardNavigationTargets',
31
+ 'firstKeyboardNavigationTarget', 'hasKeyboardNavigationModifier', 'nextKeyboardNavigationTarget',
32
+ 'nextSequentialKeyboardNavigationTarget',
33
+ 'Portal', 'PortalHost', 'PortalProvider', 'NativePortal', 'NativePortalHost', 'usePortal',
34
+ 'setupTamaguiNativePortal', 'startBridge', 'platformDriver', 'registerPseudoListener',
35
+ 'TurboModuleRegistry', 'Window', 'useAppState', 'useWindowFocused',
36
+ ]
37
+
38
+ export function rngpuiHotUpdateAliasPlugin() {
39
+ return {
40
+ name: 'rngpui-hot-update-aliases',
41
+ setup(build) {
42
+ build.onResolve({ filter: /^react$/ }, () => ({ path: 'rngpui-hot:react', namespace: 'rngpui-hot' }))
43
+ build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ path: 'rngpui-hot:react/jsx-runtime', namespace: 'rngpui-hot' }))
44
+ build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ path: 'rngpui-hot:react/jsx-dev-runtime', namespace: 'rngpui-hot' }))
45
+ build.onResolve({ filter: /^react\/compiler-runtime$/ }, () => ({ path: 'rngpui-hot:react/compiler-runtime', namespace: 'rngpui-hot' }))
46
+ build.onResolve({ filter: /^(?:@felipe-software\/)?react-native-gpui$/ }, () => ({ path: 'rngpui-hot:react-native-gpui', namespace: 'rngpui-hot' }))
47
+ build.onResolve({ filter: /^react-native$/ }, () => ({ path: 'rngpui-hot:react-native', namespace: 'rngpui-hot' }))
48
+ // Repository examples import the source entry directly. A hot bundle must
49
+ // still reuse the renderer already installed by the initial bundle.
50
+ build.onResolve({ filter: /^\.\.\/src\/index(?:\.ts)?$/ }, () => ({
51
+ path: 'rngpui-hot:react-native-gpui',
52
+ namespace: 'rngpui-hot',
53
+ }))
54
+
55
+ build.onLoad({ filter: /^rngpui-hot:react$/, namespace: 'rngpui-hot' }, () => ({
56
+ loader: 'js',
57
+ contents: moduleShim('react', REACT_EXPORTS, true),
58
+ }))
59
+ build.onLoad({ filter: /^rngpui-hot:react\/jsx-runtime$/, namespace: 'rngpui-hot' }, () => ({
60
+ loader: 'js',
61
+ contents: moduleShim('react/jsx-runtime', ['Fragment', 'jsx', 'jsxs'], true),
62
+ }))
63
+ build.onLoad({ filter: /^rngpui-hot:react\/jsx-dev-runtime$/, namespace: 'rngpui-hot' }, () => ({
64
+ loader: 'js',
65
+ contents: moduleShim('react/jsx-dev-runtime', ['Fragment', 'jsxDEV'], true),
66
+ }))
67
+ build.onLoad({ filter: /^rngpui-hot:react\/compiler-runtime$/, namespace: 'rngpui-hot' }, () => ({
68
+ loader: 'js',
69
+ contents: moduleShim('react/compiler-runtime', ['c'], false),
70
+ }))
71
+ build.onLoad({ filter: /^rngpui-hot:react-native-gpui$/, namespace: 'rngpui-hot' }, () => ({
72
+ loader: 'js',
73
+ contents: moduleShim('react-native-gpui', RNGPUI_EXPORTS, true),
74
+ }))
75
+ build.onLoad({ filter: /^rngpui-hot:react-native$/, namespace: 'rngpui-hot' }, () => ({
76
+ loader: 'js',
77
+ contents: moduleShim('react-native', RNGPUI_EXPORTS, true),
78
+ }))
79
+ },
80
+ }
81
+ }
82
+
83
+ function moduleShim(name, exports, defaultExport) {
84
+ const lines = [`const mod = globalThis.__rngpuiHotModules[${JSON.stringify(name)}];`]
85
+ if (defaultExport) lines.push('export default (mod.default || mod);')
86
+ for (const key of exports) lines.push(`export const ${key} = mod[${JSON.stringify(key)}];`)
87
+ return lines.join('\n')
88
+ }
@@ -0,0 +1,235 @@
1
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+
4
+ const packageJsonCache = new Map()
5
+
6
+ // Force React Native package entry points to win. Node/Bun pick the FIRST
7
+ // matching key in an export map, so a package that lists `browser` before
8
+ // `react-native` (nanoid, tamagui, on-zero, …) resolves to its DOM build even
9
+ // with `conditions: ['react-native']` set on the build. Bun also ignores the
10
+ // legacy top-level `react-native` field used by packages such as
11
+ // react-native-screens. This plugin overrides those, and only those.
12
+ //
13
+ // It must claim ONLY the packages it actually rewrites. A Bun 1.3.14 bundler bug
14
+ // makes an onResolve callback that MATCHES a specifier and returns `undefined`
15
+ // drop the module across an `export *` re-export edge — silently, with no build
16
+ // error, leaving a dangling namespace reference that throws at runtime
17
+ // ("Property 'import_manifest' doesn't exist"). A broad `/^[^./].*/` filter put
18
+ // every bare import in range of that bug to serve the ~7% that need the
19
+ // override. So the filter is built from the packages that really declare a
20
+ // react-native condition or entry point; everything else never enters the
21
+ // plugin and Bun resolves it natively. Keep that invariant: do not widen this
22
+ // filter.
23
+ export function nativePackageExportsPlugin({ root, name = 'native package exports' } = {}) {
24
+ const fallbackRoot = root ? resolve(root) : process.cwd()
25
+ const filter = reactNativePackageFilter(fallbackRoot)
26
+ return {
27
+ name,
28
+ setup(build) {
29
+ build.onResolve({ filter }, (args) =>
30
+ resolveReactNativePackageExport(args.path, args.importer ? dirname(args.importer) : fallbackRoot)
31
+ )
32
+ },
33
+ }
34
+ }
35
+
36
+ // every installed package whose export map declares a react-native condition
37
+ // or whose top-level react-native field names its native entry point:
38
+ // the root tree, the ancestor node_modules a workspace resolves through, and
39
+ // nested node_modules (a hoisted tree still nests duplicates). realpath dedup
40
+ // keeps a symlinked store from being walked twice or cycling. ~1800
41
+ // package.json reads, ~110ms, once per build.
42
+ function reactNativePackageFilter(root) {
43
+ const names = new Set()
44
+ const nodeModulesStack = []
45
+ for (let dir = root; ; dir = dirname(dir)) {
46
+ nodeModulesStack.push(join(dir, 'node_modules'))
47
+ if (dir === dirname(dir)) break
48
+ }
49
+ const seenNodeModules = new Set()
50
+
51
+ while (nodeModulesStack.length) {
52
+ const nodeModules = nodeModulesStack.pop()
53
+ let realNodeModules
54
+ let entries
55
+ try {
56
+ realNodeModules = realpathSync(nodeModules)
57
+ if (seenNodeModules.has(realNodeModules)) continue
58
+ seenNodeModules.add(realNodeModules)
59
+ entries = readdirSync(nodeModules, { withFileTypes: true })
60
+ } catch {
61
+ continue
62
+ }
63
+
64
+ const packageDirs = []
65
+ for (const entry of entries) {
66
+ if (entry.name.startsWith('.')) continue
67
+ const entryPath = join(nodeModules, entry.name)
68
+ let directory
69
+ try {
70
+ directory = statSync(entryPath).isDirectory()
71
+ } catch {
72
+ directory = false
73
+ }
74
+ if (!directory) continue
75
+ if (!entry.name.startsWith('@')) {
76
+ packageDirs.push(entryPath)
77
+ continue
78
+ }
79
+ let scopedEntries
80
+ try {
81
+ scopedEntries = readdirSync(entryPath, { withFileTypes: true })
82
+ } catch {
83
+ continue
84
+ }
85
+ for (const scopedEntry of scopedEntries) {
86
+ const scopedPath = join(entryPath, scopedEntry.name)
87
+ try {
88
+ if (statSync(scopedPath).isDirectory()) packageDirs.push(scopedPath)
89
+ } catch {}
90
+ }
91
+ }
92
+
93
+ for (const packageDir of packageDirs) {
94
+ const pkg = readPackageJson(join(packageDir, 'package.json'))
95
+ const values = [pkg?.exports]
96
+ let hasReactNativeTarget = false
97
+ while (values.length && !hasReactNativeTarget) {
98
+ const value = values.pop()
99
+ if (!value || typeof value !== 'object') continue
100
+ if (Object.prototype.hasOwnProperty.call(value, 'react-native')) {
101
+ hasReactNativeTarget = true
102
+ break
103
+ }
104
+ values.push(...Object.values(value))
105
+ }
106
+ if ((hasReactNativeTarget || typeof pkg?.['react-native'] === 'string') && pkg?.name) {
107
+ names.add(pkg.name)
108
+ }
109
+ const nestedNodeModules = join(packageDir, 'node_modules')
110
+ if (existsSync(nestedNodeModules)) nodeModulesStack.push(nestedNodeModules)
111
+ }
112
+ }
113
+
114
+ if (!names.size) return /\b\B/
115
+ const escaped = [...names]
116
+ .sort()
117
+ .map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
118
+ return new RegExp(`^(?:${escaped.join('|')})(?:/|$)`)
119
+ }
120
+
121
+ function resolveReactNativePackageExport(specifier, importer) {
122
+ const parsed = parsePackageSpecifier(specifier)
123
+ if (!parsed) return undefined
124
+ let defaultPath
125
+ try {
126
+ defaultPath = Bun.resolveSync(specifier, importer)
127
+ } catch {
128
+ return undefined
129
+ }
130
+ const packageJsonPath = findPackageJson(parsed.name, defaultPath, importer)
131
+ if (!packageJsonPath) return { path: defaultPath }
132
+ const pkg = readPackageJson(packageJsonPath)
133
+ if (!parsed.subpath && typeof pkg?.['react-native'] === 'string') {
134
+ const nativeEntry = pkg['react-native'].startsWith('.')
135
+ ? pkg['react-native']
136
+ : `./${pkg['react-native']}`
137
+ return { path: Bun.resolveSync(nativeEntry, dirname(packageJsonPath)) }
138
+ }
139
+ if (!pkg?.exports) return { path: defaultPath }
140
+ const exportKey = parsed.subpath ? `.${parsed.subpath}` : '.'
141
+ const match = exportValueForKey(pkg.exports, exportKey)
142
+ if (!match) return { path: defaultPath }
143
+ const target = preferredReactNativeTarget(match.value)
144
+ // bun 1.3.9 can drop a re-exported module when a matching onResolve hook
145
+ // returns undefined. once the narrowed filter matches a package name, every
146
+ // successfully resolved subpath must return the path bun selected.
147
+ if (!target) return { path: defaultPath }
148
+ const path = match.pattern ? target.replaceAll('*', match.pattern) : target
149
+ return { path: resolve(dirname(packageJsonPath), path) }
150
+ }
151
+
152
+ function parsePackageSpecifier(specifier) {
153
+ if (
154
+ specifier.startsWith('.') ||
155
+ specifier.startsWith('/') ||
156
+ specifier.startsWith('node:') ||
157
+ /^[a-zA-Z]+:/.test(specifier)
158
+ ) {
159
+ return null
160
+ }
161
+ const parts = specifier.split('/')
162
+ const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
163
+ const subpath = specifier.slice(name.length) || ''
164
+ return { name, subpath }
165
+ }
166
+
167
+ function findPackageJson(packageName, resolvedPath, importer) {
168
+ try {
169
+ return Bun.resolveSync(`${packageName}/package.json`, importer)
170
+ } catch {}
171
+ return findPackageJsonAbove(resolvedPath, packageName)
172
+ }
173
+
174
+ function findPackageJsonAbove(resolvedPath, packageName) {
175
+ let dir = dirname(resolvedPath)
176
+ while (dir !== dirname(dir)) {
177
+ const candidate = join(dir, 'package.json')
178
+ if (existsSync(candidate) && readPackageJson(candidate)?.name === packageName) return candidate
179
+ dir = dirname(dir)
180
+ }
181
+ return null
182
+ }
183
+
184
+ function readPackageJson(path) {
185
+ if (packageJsonCache.has(path)) return packageJsonCache.get(path)
186
+ let value = null
187
+ try {
188
+ value = JSON.parse(readFileSync(path, 'utf8'))
189
+ } catch {}
190
+ packageJsonCache.set(path, value)
191
+ return value
192
+ }
193
+
194
+ function exportValueForKey(exports, key) {
195
+ if (key === '.' && (typeof exports === 'string' || Array.isArray(exports))) {
196
+ return { value: exports }
197
+ }
198
+ if (!exports || typeof exports !== 'object' || Array.isArray(exports)) return null
199
+ if (Object.prototype.hasOwnProperty.call(exports, key)) return { value: exports[key] }
200
+ if (key === '.' && isConditionalExport(exports)) return { value: exports }
201
+ for (const [pattern, value] of Object.entries(exports)) {
202
+ if (!pattern.includes('*')) continue
203
+ const [prefix, suffix] = pattern.split('*')
204
+ if (!key.startsWith(prefix) || !key.endsWith(suffix)) continue
205
+ return { value, pattern: key.slice(prefix.length, key.length - suffix.length) }
206
+ }
207
+ return null
208
+ }
209
+
210
+ function isConditionalExport(value) {
211
+ return Object.keys(value).some((key) => !key.startsWith('.'))
212
+ }
213
+
214
+ function preferredReactNativeTarget(value) {
215
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
216
+ if (!Object.prototype.hasOwnProperty.call(value, 'react-native')) return null
217
+ return preferredExportTarget(value['react-native'])
218
+ }
219
+
220
+ function preferredExportTarget(value) {
221
+ if (typeof value === 'string') return value
222
+ if (Array.isArray(value)) {
223
+ for (const item of value) {
224
+ const target = preferredExportTarget(item)
225
+ if (target) return target
226
+ }
227
+ return null
228
+ }
229
+ if (!value || typeof value !== 'object') return null
230
+ for (const key of ['import', 'default', 'require']) {
231
+ const target = preferredExportTarget(value[key])
232
+ if (target) return target
233
+ }
234
+ return null
235
+ }
@@ -0,0 +1,152 @@
1
+ // Pixel / color sampling on decoded PNGs — the "what color is it actually
2
+ // rendering" primitives the conformance + parity scripts kept re-deriving by
3
+ // hand. Operates on a decoded image ({ width, height, rgba }) from png.mjs, or
4
+ // on a path/Buffer (auto-decoded). All colors come back as { r, g, b, a, hex }.
5
+ import { decodePng, readPng } from './png.mjs'
6
+
7
+ // accept a decoded {width,height,rgba}, a Buffer, or a file path.
8
+ export function toImage(src) {
9
+ if (src && typeof src === 'object' && src.rgba && typeof src.width === 'number') return src
10
+ if (Buffer.isBuffer(src)) return decodePng(src)
11
+ if (typeof src === 'string') return readPng(src)
12
+ throw new Error('expected a decoded image, png Buffer, or file path')
13
+ }
14
+
15
+ export function toHex({ r, g, b }) {
16
+ const h = (n) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0')
17
+ return `#${h(r)}${h(g)}${h(b)}`
18
+ }
19
+
20
+ // "#rrggbb" / "#rgb" / "#rrggbbaa" / "r,g,b[,a]" → { r, g, b, a }
21
+ export function parseColor(value) {
22
+ if (value && typeof value === 'object') return { a: 255, ...value }
23
+ const s = String(value).trim()
24
+ if (s.startsWith('#')) {
25
+ let hex = s.slice(1)
26
+ if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('')
27
+ if (hex.length === 6) hex += 'ff'
28
+ if (hex.length !== 8) throw new Error(`invalid hex color "${value}"`)
29
+ return {
30
+ r: parseInt(hex.slice(0, 2), 16),
31
+ g: parseInt(hex.slice(2, 4), 16),
32
+ b: parseInt(hex.slice(4, 6), 16),
33
+ a: parseInt(hex.slice(6, 8), 16),
34
+ }
35
+ }
36
+ const parts = s.split(',').map((n) => Number(n.trim()))
37
+ if (parts.length < 3 || parts.some((n) => !Number.isFinite(n))) throw new Error(`invalid color "${value}"`)
38
+ const [r, g, b, a = 255] = parts
39
+ return { r, g, b, a }
40
+ }
41
+
42
+ function withHex(color) {
43
+ return { ...color, hex: toHex(color) }
44
+ }
45
+
46
+ export function pixelAt(src, x, y) {
47
+ const img = toImage(src)
48
+ const px = Math.round(x)
49
+ const py = Math.round(y)
50
+ if (px < 0 || py < 0 || px >= img.width || py >= img.height) {
51
+ throw new Error(`pixel ${px},${py} out of bounds (${img.width}x${img.height})`)
52
+ }
53
+ const i = (py * img.width + px) * 4
54
+ return withHex({ r: img.rgba[i], g: img.rgba[i + 1], b: img.rgba[i + 2], a: img.rgba[i + 3] })
55
+ }
56
+
57
+ // clamp a rect to image bounds; default rect = whole image.
58
+ function resolveRect(img, rect) {
59
+ if (!rect) return { x: 0, y: 0, width: img.width, height: img.height }
60
+ const x = Math.max(0, Math.round(rect.x ?? 0))
61
+ const y = Math.max(0, Math.round(rect.y ?? 0))
62
+ const width = Math.min(img.width - x, Math.round(rect.width ?? img.width))
63
+ const height = Math.min(img.height - y, Math.round(rect.height ?? img.height))
64
+ if (width <= 0 || height <= 0) throw new Error(`empty rect ${JSON.stringify(rect)} for ${img.width}x${img.height}`)
65
+ return { x, y, width, height }
66
+ }
67
+
68
+ // mean color over a rect. Premultiplies by alpha so transparent pixels don't
69
+ // drag the average toward their (often garbage) rgb — and reports mean alpha.
70
+ export function averageColor(src, rect) {
71
+ const img = toImage(src)
72
+ const r = resolveRect(img, rect)
73
+ let rs = 0
74
+ let gs = 0
75
+ let bs = 0
76
+ let as = 0
77
+ let weight = 0
78
+ for (let yy = 0; yy < r.height; yy += 1) {
79
+ for (let xx = 0; xx < r.width; xx += 1) {
80
+ const i = ((r.y + yy) * img.width + (r.x + xx)) * 4
81
+ const a = img.rgba[i + 3]
82
+ const w = a / 255
83
+ rs += img.rgba[i] * w
84
+ gs += img.rgba[i + 1] * w
85
+ bs += img.rgba[i + 2] * w
86
+ as += a
87
+ weight += w
88
+ }
89
+ }
90
+ const count = r.width * r.height
91
+ const safe = weight || 1
92
+ return withHex({
93
+ r: rs / safe,
94
+ g: gs / safe,
95
+ b: bs / safe,
96
+ a: as / count,
97
+ })
98
+ }
99
+
100
+ // quantize colors into buckets and return the most-covered bucket (+ coverage
101
+ // fraction). Good for "what's the dominant background color of this area".
102
+ export function dominantColor(src, rect, { bucket = 16 } = {}) {
103
+ const img = toImage(src)
104
+ const r = resolveRect(img, rect)
105
+ const counts = new Map()
106
+ const q = (n) => Math.min(255, Math.floor(n / bucket) * bucket + Math.floor(bucket / 2))
107
+ let total = 0
108
+ for (let yy = 0; yy < r.height; yy += 1) {
109
+ for (let xx = 0; xx < r.width; xx += 1) {
110
+ const i = ((r.y + yy) * img.width + (r.x + xx)) * 4
111
+ if (img.rgba[i + 3] < 8) continue // skip fully transparent
112
+ const key = (q(img.rgba[i]) << 16) | (q(img.rgba[i + 1]) << 8) | q(img.rgba[i + 2])
113
+ counts.set(key, (counts.get(key) || 0) + 1)
114
+ total += 1
115
+ }
116
+ }
117
+ if (!total) throw new Error('region is fully transparent')
118
+ let bestKey = 0
119
+ let bestN = 0
120
+ for (const [key, n] of counts) {
121
+ if (n > bestN) {
122
+ bestN = n
123
+ bestKey = key
124
+ }
125
+ }
126
+ return {
127
+ ...withHex({ r: (bestKey >> 16) & 0xff, g: (bestKey >> 8) & 0xff, b: bestKey & 0xff, a: 255 }),
128
+ coverage: bestN / total,
129
+ distinctColors: counts.size,
130
+ }
131
+ }
132
+
133
+ // euclidean rgb distance (0 = identical, ~441 = black↔white).
134
+ export function colorDistance(a, b) {
135
+ const dr = a.r - b.r
136
+ const dg = a.g - b.g
137
+ const db = a.b - b.b
138
+ return Math.sqrt(dr * dr + dg * dg + db * db)
139
+ }
140
+
141
+ // assertion helper: does the average color of a rect match `expected` within
142
+ // `tolerance` (rgb distance)? Returns { match, actual, expected, distance }.
143
+ export function regionMatches(src, rect, expected, tolerance = 12) {
144
+ const actual = averageColor(src, rect)
145
+ const want = withHex(parseColor(expected))
146
+ const distance = colorDistance(actual, want)
147
+ return { match: distance <= tolerance, actual, expected: want, distance, tolerance }
148
+ }
149
+
150
+ function clamp(n, lo, hi) {
151
+ return Math.max(lo, Math.min(hi, n))
152
+ }
@@ -0,0 +1,198 @@
1
+ // Minimal dependency-free PNG decode/encode, shared by pixel tooling.
2
+ //
3
+ // Extracted from pixel-diff.mjs so the decoder isn't trapped inside one CLI.
4
+ // Supports the encodings our captures actually produce: 8/16-bit depth,
5
+ // non-interlaced, color types 0 (gray), 2 (rgb), 4 (gray+alpha), 6 (rgba).
6
+ // Decoded images are always normalized to 8-bit RGBA: { width, height, rgba }.
7
+ import { inflateSync, deflateSync } from 'node:zlib'
8
+ import { readFileSync } from 'node:fs'
9
+
10
+ export const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
11
+ let crcTable = null
12
+
13
+ // Read just the IHDR width/height without decoding pixels — cheap and used a lot.
14
+ export function pngSize(pathOrBuffer) {
15
+ const buf = Buffer.isBuffer(pathOrBuffer) ? pathOrBuffer : readFileSync(pathOrBuffer)
16
+ if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('not a png file')
17
+ return [buf.readUInt32BE(16), buf.readUInt32BE(20)]
18
+ }
19
+
20
+ export function readPng(path) {
21
+ return decodePng(readFileSync(path))
22
+ }
23
+
24
+ export function decodePng(buffer) {
25
+ if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
26
+ throw new Error('not a png file')
27
+ }
28
+
29
+ let width = 0
30
+ let height = 0
31
+ let bitDepth = 0
32
+ let colorType = 0
33
+ const idat = []
34
+
35
+ let offset = 8
36
+ while (offset < buffer.length) {
37
+ const length = buffer.readUInt32BE(offset)
38
+ const type = buffer.toString('ascii', offset + 4, offset + 8)
39
+ const dataStart = offset + 8
40
+ const dataEnd = dataStart + length
41
+ const data = buffer.subarray(dataStart, dataEnd)
42
+
43
+ if (type === 'IHDR') {
44
+ width = data.readUInt32BE(0)
45
+ height = data.readUInt32BE(4)
46
+ bitDepth = data[8]
47
+ colorType = data[9]
48
+ const compression = data[10]
49
+ const filter = data[11]
50
+ const interlace = data[12]
51
+ if (![8, 16].includes(bitDepth) || compression !== 0 || filter !== 0 || interlace !== 0) {
52
+ throw new Error('unsupported png encoding')
53
+ }
54
+ if (![0, 2, 4, 6].includes(colorType)) {
55
+ throw new Error(`unsupported png color type ${colorType}`)
56
+ }
57
+ } else if (type === 'IDAT') {
58
+ idat.push(data)
59
+ } else if (type === 'IEND') {
60
+ break
61
+ }
62
+
63
+ offset = dataEnd + 4
64
+ }
65
+
66
+ const bytesPerSample = bitDepth / 8
67
+ const channels = colorType === 6 ? 4 : colorType === 4 ? 2 : colorType === 2 ? 3 : 1
68
+ const raw = inflateSync(Buffer.concat(idat))
69
+ const rowBytes = width * channels * bytesPerSample
70
+ const scanlineBytes = rowBytes + 1
71
+ const unfiltered = Buffer.alloc(rowBytes * height)
72
+
73
+ for (let y = 0; y < height; y += 1) {
74
+ const filter = raw[y * scanlineBytes]
75
+ const rowStart = y * rowBytes
76
+ const sourceStart = y * scanlineBytes + 1
77
+ for (let x = 0; x < rowBytes; x += 1) {
78
+ const value = raw[sourceStart + x]
79
+ const left = x >= channels * bytesPerSample ? unfiltered[rowStart + x - channels * bytesPerSample] : 0
80
+ const up = y > 0 ? unfiltered[rowStart + x - rowBytes] : 0
81
+ const upLeft =
82
+ y > 0 && x >= channels * bytesPerSample
83
+ ? unfiltered[rowStart + x - rowBytes - channels * bytesPerSample]
84
+ : 0
85
+ switch (filter) {
86
+ case 0:
87
+ unfiltered[rowStart + x] = value
88
+ break
89
+ case 1:
90
+ unfiltered[rowStart + x] = (value + left) & 0xff
91
+ break
92
+ case 2:
93
+ unfiltered[rowStart + x] = (value + up) & 0xff
94
+ break
95
+ case 3:
96
+ unfiltered[rowStart + x] = (value + Math.floor((left + up) / 2)) & 0xff
97
+ break
98
+ case 4:
99
+ unfiltered[rowStart + x] = (value + paeth(left, up, upLeft)) & 0xff
100
+ break
101
+ default:
102
+ throw new Error(`unsupported png filter ${filter}`)
103
+ }
104
+ }
105
+ }
106
+
107
+ const rgba = Buffer.alloc(width * height * 4)
108
+ const sample = (index) => unfiltered[index]
109
+ for (let i = 0, j = 0; i < unfiltered.length; i += channels * bytesPerSample, j += 4) {
110
+ if (colorType === 6) {
111
+ rgba[j] = sample(i)
112
+ rgba[j + 1] = sample(i + bytesPerSample)
113
+ rgba[j + 2] = sample(i + bytesPerSample * 2)
114
+ rgba[j + 3] = sample(i + bytesPerSample * 3)
115
+ } else if (colorType === 2) {
116
+ rgba[j] = sample(i)
117
+ rgba[j + 1] = sample(i + bytesPerSample)
118
+ rgba[j + 2] = sample(i + bytesPerSample * 2)
119
+ rgba[j + 3] = 255
120
+ } else if (colorType === 4) {
121
+ rgba[j] = sample(i)
122
+ rgba[j + 1] = sample(i)
123
+ rgba[j + 2] = sample(i)
124
+ rgba[j + 3] = sample(i + bytesPerSample)
125
+ } else {
126
+ rgba[j] = sample(i)
127
+ rgba[j + 1] = sample(i)
128
+ rgba[j + 2] = sample(i)
129
+ rgba[j + 3] = 255
130
+ }
131
+ }
132
+
133
+ return { width, height, rgba }
134
+ }
135
+
136
+ function paeth(left, up, upLeft) {
137
+ const p = left + up - upLeft
138
+ const pa = Math.abs(p - left)
139
+ const pb = Math.abs(p - up)
140
+ const pc = Math.abs(p - upLeft)
141
+ if (pa <= pb && pa <= pc) return left
142
+ if (pb <= pc) return up
143
+ return upLeft
144
+ }
145
+
146
+ export function encodePng(width, height, rgba) {
147
+ const raw = Buffer.alloc((width * 4 + 1) * height)
148
+ for (let y = 0; y < height; y += 1) {
149
+ const rawStart = y * (width * 4 + 1)
150
+ raw[rawStart] = 0
151
+ rgba.copy(raw, rawStart + 1, y * width * 4, (y + 1) * width * 4)
152
+ }
153
+
154
+ const ihdr = Buffer.alloc(13)
155
+ ihdr.writeUInt32BE(width, 0)
156
+ ihdr.writeUInt32BE(height, 4)
157
+ ihdr[8] = 8
158
+ ihdr[9] = 6
159
+ ihdr[10] = 0
160
+ ihdr[11] = 0
161
+ ihdr[12] = 0
162
+
163
+ return Buffer.concat([
164
+ PNG_SIGNATURE,
165
+ chunk('IHDR', ihdr),
166
+ chunk('IDAT', deflateSync(raw)),
167
+ chunk('IEND', Buffer.alloc(0)),
168
+ ])
169
+ }
170
+
171
+ function chunk(type, data) {
172
+ const typeBuffer = Buffer.from(type, 'ascii')
173
+ const out = Buffer.alloc(12 + data.length)
174
+ out.writeUInt32BE(data.length, 0)
175
+ typeBuffer.copy(out, 4)
176
+ data.copy(out, 8)
177
+ out.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length)
178
+ return out
179
+ }
180
+
181
+ function crc32(buffer) {
182
+ if (!crcTable) {
183
+ crcTable = new Uint32Array(256)
184
+ for (let n = 0; n < 256; n += 1) {
185
+ let c = n
186
+ for (let k = 0; k < 8; k += 1) {
187
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
188
+ }
189
+ crcTable[n] = c >>> 0
190
+ }
191
+ }
192
+
193
+ let c = 0xffffffff
194
+ for (const byte of buffer) {
195
+ c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8)
196
+ }
197
+ return (c ^ 0xffffffff) >>> 0
198
+ }