feat(mobile): add React Native Sidebar (Opal SidebarLayouts port) (#12057)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
SubashMohanandClaude Opus 4.8 authored and GitHub committed 2026-06-14 04:13:50 +00:00
1 parent 6346812384
commit 44b7a5c6c2
23 files changed
+1018 -33

No files matched your search

+7 -3
View File
@@ -280,7 +280,11 @@ repos:
- id: mobile-format
name: mobile format
entry: bash -c 'cd mobile && bun install --frozen-lockfile && bunx prettier --write "${@#mobile/}"' _
# pass_filenames: false so this runs as a single invocation (like the
# mobile lint/typecheck hooks above). Passing filenames makes pre-commit
# split them into multiple batches that each re-run `bun install`, which
# race on bun's cache (EEXIST locally / FileNotFound on CI).
entry: bash -c 'cd mobile && bun install --frozen-lockfile && bun run format'
language: system
files: ^mobile/
types_or: [javascript, jsx, ts, tsx, json, css]
pass_filenames: false
files: ^mobile/.*\.(ts|tsx|js|jsx|json|css)$
+49
View File
@@ -7,10 +7,15 @@
"dependencies": {
"@onyx-ai/shared": "file:../web/lib/shared",
"@react-native-community/netinfo": "12.0.1",
"@rn-primitives/portal": "^1.4.0",
"@rn-primitives/separator": "^1.4.0",
"@rn-primitives/slot": "^1.4.0",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.101.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-persist-client": "^5.101.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~56.0.9",
"expo-build-properties": "~56.0.17",
"expo-constants": "~56.0.17",
@@ -31,8 +36,10 @@
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14",
},
"devDependencies": {
@@ -408,6 +415,8 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA=="],
@@ -454,6 +463,14 @@
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.85.3", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.3" }, "optionalPeers": ["@types/react"] }, "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig=="],
"@rn-primitives/portal": ["@rn-primitives/portal@1.4.0", "", { "dependencies": { "zustand": "^5.0.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-5e3v7arUuJh0yDsvK7HRKqqGOg4U2U8TIn1OU6sYPaBkQ0aoXu+ZWR6mhcXPvEBOlgDmpM++bz3unW7f4twxgA=="],
"@rn-primitives/separator": ["@rn-primitives/separator@1.4.0", "", { "dependencies": { "@radix-ui/react-separator": "^1.1.8", "@rn-primitives/slot": "1.4.0", "@rn-primitives/types": "1.4.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-Wv6miGxrqf/yYWIUDbk1l7NHU8ZCYJJkygQ8LbD6AwiVOiMJHF8q+Dfuq/Eoe3T09a+e/ctb1E4dFKfN/K/btw=="],
"@rn-primitives/slot": ["@rn-primitives/slot@1.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-p8qIYH44HgXDbsxPFIbRS/IrKWOqb2/JDT8ivFAfhL8QSW0gtVZZo8I/wFip1vV9vRl8D3zMHUBB/PrzrN0Qww=="],
"@rn-primitives/types": ["@rn-primitives/types@1.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-U7El2BbYXZG8WZrOIV4y1wpxH8aJA/sKH3SL2tZTL153ENj8aOpZ9QwyUoAU2t+sKVPDejrKjo89HeNuIuwPGQ=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@shopify/flash-list": ["@shopify/flash-list@2.0.2", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w=="],
@@ -660,6 +677,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
"bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
@@ -704,6 +723,8 @@
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
@@ -714,6 +735,8 @@
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -746,6 +769,12 @@
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -792,6 +821,14 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -804,6 +841,8 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
@@ -1242,6 +1281,8 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="],
"memfs": ["memfs@4.57.7", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.7", "@jsonjoy.com/fs-fsa": "4.57.7", "@jsonjoy.com/fs-node": "4.57.7", "@jsonjoy.com/fs-node-builtins": "4.57.7", "@jsonjoy.com/fs-node-to-fsa": "4.57.7", "@jsonjoy.com/fs-node-utils": "4.57.7", "@jsonjoy.com/fs-print": "4.57.7", "@jsonjoy.com/fs-snapshot": "4.57.7", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-YZPphUQZSRGk6ddPlsNuMbztrLwsbUATFNZcqKscSbSJZ4g0+Y3vSZLJ/rfnGZaB1FFhC7SrywZXev6i8lnHgg=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
@@ -1334,6 +1375,8 @@
"npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
"ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="],
@@ -1490,6 +1533,8 @@
"react-native-screens": ["react-native-screens@4.25.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.82.0" } }, "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg=="],
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
"react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
@@ -1666,6 +1711,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
@@ -1904,6 +1951,8 @@
"connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-import-resolver-node/resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="],
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://reactnativereusables.com/schema.json",
"style": "default",
"platform": "nativewind",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/global.css",
"baseColor": "neutral",
"cssVariables": true
},
"tsx": true,
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"utils": "@/lib/utils",
"hooks": "@/hooks"
}
}
+7
View File
@@ -5,10 +5,15 @@
"dependencies": {
"@onyx-ai/shared": "file:../web/lib/shared",
"@react-native-community/netinfo": "12.0.1",
"@rn-primitives/portal": "^1.4.0",
"@rn-primitives/separator": "^1.4.0",
"@rn-primitives/slot": "^1.4.0",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.101.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-persist-client": "^5.101.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~56.0.9",
"expo-build-properties": "~56.0.17",
"expo-constants": "~56.0.17",
@@ -29,8 +34,10 @@
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
+11 -2
View File
@@ -11,10 +11,12 @@ import { StatusBar } from "expo-status-bar";
import * as SplashScreen from "expo-splash-screen";
import { vars } from "nativewind";
import { varsLight, varsDark } from "@onyx-ai/shared/native";
import { PortalHost } from "@rn-primitives/portal";
import { persister, persistMaxAge, queryClient } from "@/query/client";
import { bindAppStateFocus } from "@/query/focus";
import { bindOnlineManager } from "@/query/online";
import { SidebarProvider } from "@/components/sidebar";
// Show the native Onyx splash until the first frame is ready, then reveal the app.
SplashScreen.preventAutoHideAsync();
@@ -56,8 +58,15 @@ export default function RootLayout() {
client={queryClient}
persistOptions={{ persister, maxAge: persistMaxAge }}
>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }} />
{/* SidebarProvider owns the shared open/closed (folded) state so any screen
can open the sidebar and the portalled overlay can read it. PortalHost is
the last child of the themed root, so the overlay renders above all screens
while still inheriting the vars() theme + safe-area insets. */}
<SidebarProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }} />
<PortalHost />
</SidebarProvider>
</PersistQueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
+120 -16
View File
@@ -1,25 +1,129 @@
import { Text, View } from "react-native";
import { useState } from "react";
import { Pressable, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
// Home screen — styled with @onyx-ai/shared design tokens. The semantic classes
// (bg-background-neutral-*, text-text-*, rounded-12) resolve through the vars()
// provider in _layout.tsx, so colors flip with the system light/dark scheme exactly
// like web. Spacing/radius come from the shared token scale too.
import { Icon } from "@/components/ui/icon";
import { Text } from "@/components/ui/text";
import { SidebarLayouts, SidebarTab, useSidebar } from "@/components/sidebar";
import SvgSidebar from "@/icons/sidebar";
import SvgBubbleText from "@/icons/bubble-text";
import SvgSearch from "@/icons/search";
import SvgFolder from "@/icons/folder";
import SvgPlus from "@/icons/plus";
import SvgSettings from "@/icons/settings";
// Demo of the React Native Sidebar — a port of the web Opal `SidebarLayouts` shell.
// Tap the menu icon to slide the sidebar in over the screen; tap the backdrop or
// swipe left to close. Colors resolve from @onyx-ai/shared, identical to web in
// both light and dark.
function SidebarTrigger() {
const { setFolded } = useSidebar();
return (
<Pressable
onPress={() => setFolded(false)}
hitSlop={8}
className="rounded-08 p-2 active:bg-background-tint-03"
>
<Icon as={SvgSidebar} size={24} className="text-text-04" />
</Pressable>
);
}
export default function Home() {
const { setFolded } = useSidebar();
const [selected, setSelected] = useState("general");
function choose(key: string) {
setSelected(key);
setFolded(true); // auto-dismiss after selection (demo behavior)
}
return (
<SafeAreaView className="flex-1 bg-background-neutral-00">
<View className="flex-1 items-center justify-center gap-2 px-6">
<Text className="text-2xl font-semibold text-text-04">Onyx Mobile</Text>
<Text className="text-center text-base text-text-03">
Design tokens via @onyx-ai/shared — colors, spacing & radius, light &
dark.
</Text>
<View className="mt-2 rounded-12 bg-background-neutral-02 px-4 py-2">
<Text className="text-text-03">
rounded-12 · background-neutral-02
</Text>
</View>
<View className="flex-row items-center gap-2 px-4 py-3">
<SidebarTrigger />
<Text font="main-ui-action">Onyx Mobile</Text>
</View>
<View className="flex-1 items-center justify-center px-6">
<Text font="main-content-body" className="text-center text-text-03">
Tap the menu icon to open the sidebar. Tap the backdrop or swipe left
to close.
</Text>
</View>
<SidebarLayouts.Root foldable>
<SidebarLayouts.Header
logo={() => (
<Text font="heading-h3" className="px-1">
Onyx
</Text>
)}
/>
<SidebarLayouts.Body scrollKey="demo">
<SidebarLayouts.Section title="Chats">
<SidebarTab
icon={SvgBubbleText}
selected={selected === "general"}
onPress={() => choose("general")}
>
General
</SidebarTab>
<SidebarTab
icon={SvgSearch}
selected={selected === "search"}
onPress={() => choose("search")}
>
Search
</SidebarTab>
</SidebarLayouts.Section>
<SidebarLayouts.Section
title="Projects"
action={
<Pressable
hitSlop={8}
className="rounded-08 p-1 active:bg-background-tint-03"
>
<Icon as={SvgPlus} size={16} className="text-text-03" />
</Pressable>
}
>
<SidebarTab
icon={SvgFolder}
selected={selected === "onboarding"}
onPress={() => choose("onboarding")}
rightChildren={
<Text font="secondary-body" className="text-text-03">
3
</Text>
}
>
Onboarding
</SidebarTab>
<SidebarTab
nested
selected={selected === "spec"}
onPress={() => choose("spec")}
>
Spec draft
</SidebarTab>
</SidebarLayouts.Section>
</SidebarLayouts.Body>
<SidebarLayouts.Footer>
<SidebarTab
icon={SvgSettings}
variant="sidebar-light"
selected={selected === "settings"}
onPress={() => choose("settings")}
>
Settings
</SidebarTab>
</SidebarLayouts.Footer>
</SidebarLayouts.Root>
</SafeAreaView>
);
}
+244
View File
@@ -0,0 +1,244 @@
import * as React from "react";
import { Pressable, ScrollView, View } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Portal } from "@rn-primitives/portal";
import { cn } from "@/lib/utils";
import { Icon } from "@/components/ui/icon";
import { Text } from "@/components/ui/text";
import SvgSidebar from "@/icons/sidebar";
import { useSidebar } from "@/components/sidebar/SidebarProvider";
import {
SIDEBAR_ANIM_MS,
SIDEBAR_WIDTH_EXPANDED,
} from "@/components/sidebar/interfaces";
// ---------------------------------------------------------------------------
// SidebarLayouts — the RN sidebar shell (Root/Header/Body/Footer/Section). A
// full-height overlay that slides in from the left with a tinted backdrop:
// • portalled above all screens via @rn-primitives/portal,
// • reanimated translateX slide + backdrop opacity off the `folded` state,
// • gesture-handler Pan for swipe-to-close (gated to horizontal so it doesn't
// steal the Body ScrollView's vertical scroll),
// • colors + typography from the shared design tokens.
// ---------------------------------------------------------------------------
interface SidebarRootProps {
/** Kept for web API parity; the mobile overlay behaves the same regardless. */
foldable?: boolean;
children: React.ReactNode;
}
function SidebarRoot({ children }: SidebarRootProps) {
const { folded, setFolded } = useSidebar();
const insets = useSafeAreaInsets();
// `progress` is derived (read-only) from the React `folded` state: 1 = open,
// 0 = closed (off-screen). Driving it via useDerivedValue keeps the open/close
// animation purely state-driven — no manual shared-value mutation in an effect.
const progress = useDerivedValue(
() => withTiming(folded ? 0 : 1, { duration: SIDEBAR_ANIM_MS }),
[folded],
);
// `drag` is a live swipe offset, mutated ONLY inside the Pan worklet and read by
// the animated styles — it layers on top of `progress` so a finger drag tracks 1:1.
const drag = useSharedValue(0);
const columnStyle = useAnimatedStyle(() => {
const base = (progress.value - 1) * SIDEBAR_WIDTH_EXPANDED;
return { transform: [{ translateX: Math.min(0, base + drag.value) }] };
});
const backdropStyle = useAnimatedStyle(() => {
const dragFade = 1 + drag.value / SIDEBAR_WIDTH_EXPANDED;
return { opacity: progress.value * Math.max(0, Math.min(1, dragFade)) };
});
const close = React.useCallback(() => setFolded(true), [setFolded]);
// Swipe left on the column to close. `activeOffsetX` gates activation to
// horizontal movement, letting vertical scroll fall through to the Body. Built
// inline (not via a hook) so `drag` isn't treated as a frozen hook argument —
// useSharedValue refs are stable, so re-creating the gesture object is cheap and
// the Root only re-renders when `folded` flips.
const pan = Gesture.Pan()
.activeOffsetX([-15, 15])
.onUpdate((e) => {
drag.value = Math.min(0, e.translationX); // leftward only
})
.onEnd((e) => {
const shouldClose =
e.translationX < -SIDEBAR_WIDTH_EXPANDED / 3 || e.velocityX < -500;
drag.value = withTiming(0, { duration: SIDEBAR_ANIM_MS });
if (shouldClose) runOnJS(setFolded)(true);
});
return (
<Portal name="sidebar">
<View
pointerEvents={folded ? "none" : "auto"}
className="absolute inset-0 z-50"
>
{/* Tinted backdrop (web `bg-mask-03`; the ~1px backdrop-blur is dropped — not
a mobile token). Tap to close. */}
<Animated.View style={backdropStyle} className="absolute inset-0">
<Pressable className="flex-1 bg-mask-03" onPress={close} />
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
style={[
columnStyle,
{
width: SIDEBAR_WIDTH_EXPANDED,
paddingTop: insets.top,
paddingBottom: insets.bottom,
},
]}
className="absolute bottom-0 left-0 top-0 bg-background-tint-02 pb-2"
>
<View className="min-h-0 flex-1 flex-col">{children}</View>
</Animated.View>
</GestureDetector>
</View>
</Portal>
);
}
// ---------------------------------------------------------------------------
// Header — topbar (logo + close button) with optional pinned content below
// ---------------------------------------------------------------------------
interface SidebarHeaderProps {
logo?: (folded: boolean | undefined) => React.ReactNode;
/** Kept for web API parity (no hover on touch). */
showLogoWhenFolded?: boolean;
children?: React.ReactNode;
}
function SidebarHeader({ logo, children }: SidebarHeaderProps) {
const { folded, setFolded } = useSidebar();
const logoEl = logo != null ? logo(folded) : null;
if (logoEl == null && children == null) return null;
return (
<View className="gap-4 pb-2">
{logoEl != null && (
<View className="flex-row items-start justify-between px-2 pt-3">
{logoEl}
<Pressable
onPress={() => setFolded(true)}
hitSlop={8}
className="rounded-08 p-1 active:bg-background-tint-03"
>
<Icon as={SvgSidebar} size={20} className="text-text-03" />
</Pressable>
</View>
)}
{children != null && <View className="px-2">{children}</View>}
</View>
);
}
// ---------------------------------------------------------------------------
// Body — scrollable content area
// ---------------------------------------------------------------------------
interface SidebarBodyProps {
/** Accepted for web API parity; scroll-offset persistence is out of scope for the shell. */
scrollKey?: string;
children?: React.ReactNode;
}
function SidebarBody({ children }: SidebarBodyProps) {
return (
<ScrollView className="min-h-0 flex-1" showsVerticalScrollIndicator={false}>
<View className="gap-2 px-2 pb-8">{children}</View>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Footer — pinned content below the scroll area
// ---------------------------------------------------------------------------
interface SidebarFooterProps {
children?: React.ReactNode;
}
function SidebarFooter({ children }: SidebarFooterProps) {
return <View className="px-2 pt-4">{children}</View>;
}
// ---------------------------------------------------------------------------
// Section — titled group within the scrollable body
// ---------------------------------------------------------------------------
interface SidebarSectionProps {
title?: string;
/** Optional action (e.g. a "+" button); always visible on touch (no hover). */
action?: React.ReactNode;
/** Dims the section header to indicate it is unavailable. */
disabled?: boolean;
children?: React.ReactNode;
}
function SidebarSection({
title,
action,
disabled,
children,
}: SidebarSectionProps) {
return (
<View className="flex-col">
{title ? (
<View
className={cn(
"flex-row items-center justify-between pb-1 pl-2 pt-2",
disabled && "opacity-50",
)}
>
<View className="p-0.5">
<Text font="secondary-body" className="text-text-02">
{title}
</Text>
</View>
{action != null && <View>{action}</View>}
</View>
) : (
<View className="h-2" />
)}
<View className="gap-1">{children}</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Exports — mirror the web `SidebarLayouts` namespace
// ---------------------------------------------------------------------------
export const SidebarLayouts = {
Root: SidebarRoot,
Header: SidebarHeader,
Body: SidebarBody,
Footer: SidebarFooter,
Section: SidebarSection,
};
export type {
SidebarRootProps,
SidebarHeaderProps,
SidebarBodyProps,
SidebarFooterProps,
SidebarSectionProps,
};
@@ -0,0 +1,54 @@
import {
createContext,
useContext,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
// ---------------------------------------------------------------------------
// Sidebar state. `folded` is the single source of truth: on a phone the sidebar
// is an overlay, so `folded === true` means "closed / off-screen" and
// `folded === false` means "open". Default closed; opened from a trigger.
//
// `setFolded` is React's own `useState` setter, so it already accepts either a
// value (`setFolded(false)`) or an updater (`setFolded((prev) => !prev)` for a
// toggle) — no wrapper needed.
// ---------------------------------------------------------------------------
export interface SidebarStateContextType {
folded: boolean;
setFolded: Dispatch<SetStateAction<boolean>>;
}
const SidebarStateContext = createContext<SidebarStateContextType | undefined>(
undefined,
);
export interface SidebarProviderProps {
/** Initial fold state. Defaults to `true` (closed) on mobile. */
defaultFolded?: boolean;
children: ReactNode;
}
export function SidebarProvider({
defaultFolded = true,
children,
}: SidebarProviderProps) {
const [folded, setFolded] = useState(defaultFolded);
return (
<SidebarStateContext.Provider value={{ folded, setFolded }}>
{children}
</SidebarStateContext.Provider>
);
}
export function useSidebar(): SidebarStateContextType {
const context = useContext(SidebarStateContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
}
@@ -0,0 +1,148 @@
import * as React from "react";
import { Pressable, View } from "react-native";
import { router, type Href } from "expo-router";
import { cn } from "@/lib/utils";
import { Icon } from "@/components/ui/icon";
import { Text } from "@/components/ui/text";
import type { IconFunctionComponent } from "@/icons/types";
import type { SidebarVariant } from "@/components/sidebar/interfaces";
// ---------------------------------------------------------------------------
// SidebarTab — React Native port of web's Opal SidebarTab
// (web/lib/opal/src/components/buttons/sidebar-tab/components.tsx), built on the
// `Interactive.Stateful` sidebar-heavy / sidebar-light color matrix
// (web/lib/opal/src/core/interactive/stateful/styles.css:543-662).
//
// Web "hover" → RN "pressed" (NativeWind's `active:` modifier on Pressable).
// All classes resolve to the same Onyx token hex as web.
// ---------------------------------------------------------------------------
interface SidebarTabColors {
/** Selected background; empty/filled is transparent (omitted). */
bg: string;
/** Label foreground color class. */
label: string;
/** Icon foreground. */
icon: string;
}
function resolveColors(
variant: SidebarVariant,
selected: boolean,
disabled: boolean,
): SidebarTabColors {
if (disabled) {
return { bg: "", label: "text-text-03", icon: "text-text-03" };
}
if (variant === "sidebar-light") {
// All states use text-02 foreground; selected adds the tint-00 background.
return {
bg: selected ? "bg-background-tint-00" : "",
label: "text-text-02",
icon: "text-text-02",
};
}
// sidebar-heavy
if (selected) {
return {
bg: "bg-background-tint-00",
label: "text-text-04",
icon: "text-text-03",
};
}
return { bg: "", label: "text-text-03", icon: "text-text-02" };
}
interface SidebarTabProps {
/** Collapses the label, showing only the icon (inert on phone — sidebar is open/closed). */
folded?: boolean;
/** Marks this tab as the currently active item. */
selected?: boolean;
/** Color variant. @default "sidebar-heavy" */
variant?: SidebarVariant;
/** Renders an empty spacer in place of the icon, for nested items. */
nested?: boolean;
/** Disables the tab — muted colors, suppressed press. */
disabled?: boolean;
onPress?: () => void;
/** Optional route to navigate to on press (expo-router). */
href?: Href;
icon?: IconFunctionComponent;
/** Content rendered on the right (e.g. a count or action). */
rightChildren?: React.ReactNode;
/** Accepted for web API parity; a no-op on touch. */
tooltip?: string;
children?: React.ReactNode;
}
function SidebarTab({
folded,
selected = false,
variant = "sidebar-heavy",
nested,
disabled = false,
onPress,
href,
icon,
rightChildren,
children,
}: SidebarTabProps) {
const colors = resolveColors(variant, selected, disabled);
function handlePress() {
// `disabled` Pressable already blocks onPress, so no guard needed here.
onPress?.();
if (href != null) router.navigate(href);
}
return (
<Pressable
disabled={disabled}
onPress={handlePress}
className={cn(
"h-9 w-full flex-row items-center rounded-08 px-2",
colors.bg,
!disabled && "active:bg-background-tint-03",
disabled && "opacity-50",
)}
>
{/* Leading slot — mirrors web's ContentSm (web/lib/opal/.../content/ContentSm.tsx
+ styles.css): a 16px icon inside a `p-0.5` container (the padding "around"
the icon), then a 2px gap (web's `gap: 0.125rem`) before the label, or a
matching spacer for nested items so labels align. Explicit margin instead of
`gap`, which doesn't render reliably in RN/NativeWind. */}
{nested ? (
<View className="mr-0.5 w-5" aria-hidden />
) : icon ? (
<View className="mr-0.5 items-center justify-center p-0.5">
<Icon as={icon} size={16} className={colors.icon} />
</View>
) : null}
{!folded && (
<>
{typeof children === "string" ? (
<Text
font="main-ui-body"
numberOfLines={1}
className={cn("flex-1", colors.label)}
>
{children}
</Text>
) : (
<View className="flex-1">{children}</View>
)}
{rightChildren && (
<View className="ml-auto flex-row items-center">
{rightChildren}
</View>
)}
</>
)}
</Pressable>
);
}
export { SidebarTab, type SidebarTabProps };
+19
View File
@@ -0,0 +1,19 @@
export { SidebarLayouts } from "@/components/sidebar/Sidebar";
export type {
SidebarRootProps,
SidebarHeaderProps,
SidebarBodyProps,
SidebarFooterProps,
SidebarSectionProps,
} from "@/components/sidebar/Sidebar";
export {
SidebarTab,
type SidebarTabProps,
} from "@/components/sidebar/SidebarTab";
export {
SidebarProvider,
useSidebar,
type SidebarProviderProps,
type SidebarStateContextType,
} from "@/components/sidebar/SidebarProvider";
export type { SidebarVariant } from "@/components/sidebar/interfaces";
@@ -0,0 +1,12 @@
// Shared (non-prop) types for the Sidebar primitive. Mirrors the web Opal
// `Interactive.Stateful` sidebar variants.
export type SidebarVariant = "sidebar-heavy" | "sidebar-light";
// Sidebar column width, ported from web/lib/opal/src/styles/sizes.css
// (--sidebar-width-expanded: 15rem). On a phone the folded rail is unused
// (folded === off-screen), so only the expanded width applies.
export const SIDEBAR_WIDTH_EXPANDED = 240;
// Slide/backdrop transition duration — matches web's `duration-200`.
export const SIDEBAR_ANIM_MS = 200;
+45
View File
@@ -0,0 +1,45 @@
// Why this exists: React Native has no CSS engine, so a react-native-svg icon can't
// read a Tailwind class like `text-text-02` (on web the browser does this for free).
// This wrapper is that missing translator — it turns a color class into a real color
// and hands it to the icon, so icons use the same design tokens as everything else.
//
// How: `cssInterop` compiles the className and maps the resolved text color → the
// icon's `color` prop, which the SVG's `stroke="currentColor"` reads. Adds a default
// size (16) + color (`text-text-03`).
import { cssInterop } from "nativewind";
import { cn } from "@/lib/utils";
import type { IconFunctionComponent, IconProps } from "@/icons/types";
type IconWrapperProps = IconProps & {
as: IconFunctionComponent;
};
function IconImpl({ as: IconComponent, ...props }: IconWrapperProps) {
return <IconComponent {...props} />;
}
cssInterop(IconImpl, {
className: {
target: "style",
nativeStyleToProp: { color: true },
},
});
/**
* @example
* import SvgSearch from "@/icons/search";
* <Icon as={SvgSearch} size={16} className="text-text-02" />
*/
function Icon({ as, className, size = 16, ...props }: IconWrapperProps) {
return (
<IconImpl
as={as}
className={cn("text-text-03", className)}
size={size}
{...props}
/>
);
}
export { Icon };
+26
View File
@@ -0,0 +1,26 @@
// react-native-reusables `separator` primitive, vendored and reconciled to Onyx tokens
// (RNR default `bg-border` → `bg-border-01`).
import { cn } from "@/lib/utils";
import * as SeparatorPrimitive from "@rn-primitives/separator";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border-01",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
);
}
export { Separator };
+52
View File
@@ -0,0 +1,52 @@
// Text — the React Native counterpart of web's Opal `Text`. Typography comes from
// the shared design-system presets (`@onyx-ai/shared` `textPresets`), selected by a
// `font` prop with the exact same names as web (`TextFont`): "main-ui-body",
// "secondary-body", "heading-h3", … Each preset carries family + size + weight +
// line-height + letter-spacing, so we never hand-pick `font-semibold`/`text-base`.
// Color stays as a token className (e.g. `text-text-02`), identical to web.
//
// Built on the RNR `text` skeleton (Slot + `asChild`).
//
// FOLLOW-UP PR — this does NOT yet fully match web's Opal `Text`. Out of scope here;
// to be reconciled in a dedicated PR:
// • Add a typed `color?: TextColor` prop (web has it). Color is an untyped
// `className` today, so token typos aren't caught.
// • Canonicalize `TextFont` + `TextColor` in `@onyx-ai/shared` as the single source
// and have both web (Opal) and mobile import them (today `TextFont` is generated
// for mobile only; `TextColor` lives hand-written in Opal).
// • Optionally expose `font-*` className utilities (web has `@utility font-heading-h1`)
// via a NativeWind plugin, for className parity with web.
import { textPresets, type TextFont } from "@onyx-ai/shared/native";
import { Slot } from "@rn-primitives/slot";
import * as React from "react";
import { Text as RNText, type TextStyle } from "react-native";
import { cn } from "@/lib/utils";
interface TextProps extends React.ComponentProps<typeof RNText> {
/** Typography preset from the shared design system (same names as web's Opal Text). */
font?: TextFont;
asChild?: boolean;
}
function Text({
className,
font = "main-ui-body",
asChild = false,
style,
...props
}: TextProps) {
const Component = asChild ? Slot : RNText;
// `as TextStyle`: presets type fontWeight as `string` (RN narrows it to a union);
// the resolved values ("400"/"500"/…) are valid TextStyle at runtime.
const preset = textPresets[font] as TextStyle;
return (
<Component
style={[preset, style]}
className={cn("text-text-04", className)}
{...props}
/>
);
}
export { Text, type TextFont };
+24
View File
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN variant of web Opal `SvgBubbleText` (web/lib/opal/src/icons/bubble-text.tsx).
const SvgBubbleText = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
{...props}
>
<Path
d="M10.4939 6.5H5.5M8.00607 9.5H5.50607M1.5 13.5H10.5C12.7091 13.5 14.5 11.7091 14.5 9.5V6.5C14.5 4.29086 12.7091 2.5 10.5 2.5H5.5C3.29086 2.5 1.5 4.29086 1.5 6.5V13.5Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgBubbleText;
+24
View File
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN variant of web Opal `SvgFolder` (web/lib/opal/src/icons/folder.tsx).
const SvgFolder = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
{...props}
>
<Path
d="M14.5 12V6C14.5 5.17157 13.8284 4.5 13 4.5H9.12132C8.7235 4.5 8.34196 4.34196 8.06066 4.06066L6.93934 2.93934C6.65804 2.65804 6.2765 2.5 5.87868 2.5H3C2.17157 2.5 1.5 3.17157 1.5 4V12C1.5 12.8284 2.17157 13.5 3 13.5H13C13.8284 13.5 14.5 12.8284 14.5 12Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgFolder;
+24
View File
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN variant of web Opal `SvgPlus` (web/lib/opal/src/icons/plus.tsx).
const SvgPlus = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
{...props}
>
<Path
d="M8 2V14M2 8H14"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgPlus;
+24
View File
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN variant of web Opal `SvgSearch` (web/lib/opal/src/icons/search.tsx).
const SvgSearch = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
{...props}
>
<Path
d="M14 14L11.1 11.1M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgSearch;
+32
View File
@@ -0,0 +1,32 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN variant of web Opal `SvgSettings` (web/lib/opal/src/icons/settings.tsx). The
// web source wraps the paths in a clipPath bounded to the full 16x16 viewBox (a
// visual no-op), so it's omitted here.
const SvgSettings = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
{...props}
>
<Path
d="M8.00001 9.99999C9.10457 9.99999 10 9.10456 10 7.99999C10 6.89542 9.10457 5.99999 8.00001 5.99999C6.89544 5.99999 6.00001 6.89542 6.00001 7.99999C6.00001 9.10456 6.89544 9.99999 8.00001 9.99999Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M12.9333 9.99999C12.8446 10.2011 12.8181 10.4241 12.8573 10.6404C12.8965 10.8566 12.9996 11.0562 13.1533 11.2133L13.1933 11.2533C13.3173 11.3772 13.4157 11.5242 13.4828 11.6861C13.5499 11.8479 13.5844 12.0214 13.5844 12.1967C13.5844 12.3719 13.5499 12.5454 13.4828 12.7072C13.4157 12.8691 13.3173 13.0162 13.1933 13.14C13.0695 13.264 12.9225 13.3623 12.7606 13.4294C12.5987 13.4965 12.4252 13.531 12.25 13.531C12.0748 13.531 11.9013 13.4965 11.7394 13.4294C11.5776 13.3623 11.4305 13.264 11.3067 13.14L11.2667 13.1C11.1096 12.9463 10.91 12.8432 10.6937 12.804C10.4775 12.7648 10.2544 12.7912 10.0533 12.88C9.85616 12.9645 9.68799 13.1048 9.56954 13.2837C9.45109 13.4625 9.38753 13.6721 9.38667 13.8867V14C9.38667 14.3536 9.2462 14.6928 8.99615 14.9428C8.7461 15.1928 8.40696 15.3333 8.05334 15.3333C7.69972 15.3333 7.36058 15.1928 7.11053 14.9428C6.86048 14.6928 6.72001 14.3536 6.72001 14V13.94C6.71484 13.7193 6.64342 13.5053 6.51501 13.3258C6.38661 13.1463 6.20716 13.0095 6.00001 12.9333C5.79893 12.8446 5.57588 12.8181 5.35961 12.8573C5.14335 12.8965 4.94379 12.9996 4.78667 13.1533L4.74667 13.1933C4.62284 13.3173 4.47579 13.4156 4.31393 13.4827C4.15206 13.5498 3.97856 13.5844 3.80334 13.5844C3.62812 13.5844 3.45462 13.5498 3.29275 13.4827C3.13089 13.4156 2.98384 13.3173 2.86001 13.1933C2.73604 13.0695 2.63769 12.9224 2.57059 12.7606C2.50349 12.5987 2.46896 12.4252 2.46896 12.25C2.46896 12.0748 2.50349 11.9013 2.57059 11.7394C2.63769 11.5775 2.73604 11.4305 2.86001 11.3067L2.90001 11.2667C3.0537 11.1095 3.1568 10.91 3.19601 10.6937C3.23522 10.4775 3.20875 10.2544 3.12001 10.0533C3.0355 9.85614 2.89518 9.68798 2.71632 9.56953C2.53746 9.45108 2.32786 9.38751 2.11334 9.38666H2.00001C1.64638 9.38666 1.30724 9.24618 1.0572 8.99613C0.807148 8.74608 0.666672 8.40695 0.666672 8.05332C0.666672 7.6997 0.807148 7.36056 1.0572 7.11051C1.30724 6.86047 1.64638 6.71999 2.00001 6.71999H2.06001C2.28067 6.71483 2.49467 6.6434 2.6742 6.515C2.85373 6.38659 2.99048 6.20715 3.06667 5.99999C3.15542 5.79891 3.18189 5.57586 3.14267 5.3596C3.10346 5.14333 3.00036 4.94378 2.84667 4.78666L2.80667 4.74666C2.6827 4.62283 2.58436 4.47577 2.51726 4.31391C2.45016 4.15205 2.41562 3.97854 2.41562 3.80332C2.41562 3.6281 2.45016 3.4546 2.51726 3.29274C2.58436 3.13087 2.6827 2.98382 2.80667 2.85999C2.9305 2.73602 3.07755 2.63768 3.23942 2.57058C3.40128 2.50348 3.57478 2.46894 3.75001 2.46894C3.92523 2.46894 4.09873 2.50348 4.26059 2.57058C4.42246 2.63768 4.56951 2.73602 4.69334 2.85999L4.73334 2.89999C4.89046 3.05368 5.09002 3.15678 5.30628 3.19599C5.52254 3.23521 5.74559 3.20873 5.94667 3.11999H6.00001C6.19718 3.03548 6.36535 2.89516 6.4838 2.7163C6.60225 2.53744 6.66582 2.32785 6.66667 2.11332V1.99999C6.66667 1.64637 6.80715 1.30723 7.0572 1.05718C7.30725 0.807132 7.64638 0.666656 8.00001 0.666656C8.35363 0.666656 8.69277 0.807132 8.94281 1.05718C9.19286 1.30723 9.33334 1.64637 9.33334 1.99999V2.05999C9.33419 2.27451 9.39776 2.48411 9.51621 2.66297C9.63466 2.84183 9.80283 2.98215 10 3.06666C10.2011 3.1554 10.4241 3.18187 10.6404 3.14266C10.8567 3.10345 11.0562 3.00035 11.2133 2.84666L11.2533 2.80666C11.3772 2.68269 11.5242 2.58434 11.6861 2.51724C11.8479 2.45014 12.0215 2.41561 12.1967 2.41561C12.3719 2.41561 12.5454 2.45014 12.7073 2.51724C12.8691 2.58434 13.0162 2.68269 13.14 2.80666C13.264 2.93049 13.3623 3.07754 13.4294 3.2394C13.4965 3.40127 13.5311 3.57477 13.5311 3.74999C13.5311 3.92521 13.4965 4.09871 13.4294 4.26058C13.3623 4.42244 13.264 4.56949 13.14 4.69332L13.1 4.73332C12.9463 4.89044 12.8432 5.09 12.804 5.30626C12.7648 5.52253 12.7913 5.74558 12.88 5.94666V5.99999C12.9645 6.19717 13.1048 6.36533 13.2837 6.48379C13.4626 6.60224 13.6721 6.6658 13.8867 6.66666H14C14.3536 6.66666 14.6928 6.80713 14.9428 7.05718C15.1929 7.30723 15.3333 7.64637 15.3333 7.99999C15.3333 8.35361 15.1929 8.69275 14.9428 8.9428C14.6928 9.19285 14.3536 9.33332 14 9.33332H13.94C13.7255 9.33418 13.5159 9.39774 13.337 9.5162C13.1582 9.63465 13.0178 9.80281 12.9333 9.99999Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgSettings;
+24
View File
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
import type { IconProps } from "@/icons/types";
// RN variant of web Opal `SvgSidebar` (web/lib/opal/src/icons/sidebar.tsx).
const SvgSidebar = ({ size = 16, ...props }: IconProps) => (
<Svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
{...props}
>
<Path
d="M6 2V14M3.33333 2H12.6667C13.403 2 14 2.59695 14 3.33333V12.6667C14 13.403 13.403 14 12.6667 14H3.33333C2.59695 14 2 13.403 2 12.6667V3.33333C2 2.59695 2.59695 2 3.33333 2Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
export default SvgSidebar;
+14
View File
@@ -0,0 +1,14 @@
import type * as React from "react";
import type { SvgProps } from "react-native-svg";
// React Native counterpart of web Opal's icon types (web/lib/opal/src/types.ts).
// Same shape, retargeted to react-native-svg. Icons render with
// `stroke="currentColor"`, so color flows from the `color` prop — the `Icon`
// wrapper (components/ui/icon.tsx) sets it from a `text-*` token class.
export interface IconProps extends SvgProps {
className?: string;
size?: number;
title?: string;
}
export type IconFunctionComponent = React.FunctionComponent<IconProps>;
+9
View File
@@ -0,0 +1,9 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// Single class-merge helper for every UI component (mirrors web's @/lib/utils `cn`).
// clsx flattens conditionals/arrays; twMerge resolves conflicting Tailwind utilities
// so a later class (e.g. an explicit `text-text-02`) wins over a base default.
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+30 -12
View File
@@ -199,18 +199,36 @@ StyleDictionary.registerFormat({
// generates that JS, not its declarations, so they're spelled out here.
StyleDictionary.registerFormat({
name: "dts/native-vars",
format: () =>
"// Generated by Style Dictionary. Do not edit by hand.\n" +
"export declare const varsLight: Record<string, string>;\n" +
"export declare const varsDark: Record<string, string>;\n" +
"export interface TextPreset {\n" +
" fontFamily: string;\n" +
" fontSize: number;\n" +
" fontWeight: string;\n" +
" lineHeight: number;\n" +
" letterSpacing: number;\n" +
"}\n" +
"export declare const textPresets: Record<string, TextPreset>;\n",
format: ({ dictionary }) => {
// TextFont = the union of preset names (unique path[1] of typography tokens,
// path[0] === "text"), so the type ships WITH the tokens and mobile's Text
// component can accept `font="main-ui-body"` exactly like web's Opal Text.
const fontNames = [
...new Set(
dictionary.allTokens
.filter((t) => t.path[0] === "text")
.map((t) => t.path[1])
),
];
const textFontUnion =
fontNames.length > 0
? fontNames.map((n) => `"${n}"`).join(" | ")
: "never";
return (
"// Generated by Style Dictionary. Do not edit by hand.\n" +
"export declare const varsLight: Record<string, string>;\n" +
"export declare const varsDark: Record<string, string>;\n" +
"export interface TextPreset {\n" +
" fontFamily: string;\n" +
" fontSize: number;\n" +
" fontWeight: string;\n" +
" lineHeight: number;\n" +
" letterSpacing: number;\n" +
"}\n" +
`export type TextFont = ${textFontUnion};\n` +
"export declare const textPresets: Record<TextFont, TextPreset>;\n"
);
},
});
// FORMAT -> native.js (mobile, ESM). The RN analog of web's `.dark`: two fully