diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98ad209745..be5d9bf1e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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)$ diff --git a/mobile/bun.lock b/mobile/bun.lock index 27bcea1a2d..b4a6da38a1 100644 --- a/mobile/bun.lock +++ b/mobile/bun.lock @@ -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=="], diff --git a/mobile/components.json b/mobile/components.json new file mode 100644 index 0000000000..001dba3abf --- /dev/null +++ b/mobile/components.json @@ -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" + } +} diff --git a/mobile/package.json b/mobile/package.json index f9485478de..8a318758e8 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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": { diff --git a/mobile/src/app/_layout.tsx b/mobile/src/app/_layout.tsx index 3ef8440009..66d467e8d0 100644 --- a/mobile/src/app/_layout.tsx +++ b/mobile/src/app/_layout.tsx @@ -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 }} > - - + {/* 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. */} + + + + + diff --git a/mobile/src/app/index.tsx b/mobile/src/app/index.tsx index 00f05cdd70..68c7e74573 100644 --- a/mobile/src/app/index.tsx +++ b/mobile/src/app/index.tsx @@ -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 ( + setFolded(false)} + hitSlop={8} + className="rounded-08 p-2 active:bg-background-tint-03" + > + + + ); +} + 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 ( - - Onyx Mobile - - Design tokens via @onyx-ai/shared — colors, spacing & radius, light & - dark. - - - - rounded-12 · background-neutral-02 - - + + + Onyx Mobile + + + + Tap the menu icon to open the sidebar. Tap the backdrop or swipe left + to close. + + + + + ( + + Onyx + + )} + /> + + + + choose("general")} + > + General + + choose("search")} + > + Search + + + + + + + } + > + choose("onboarding")} + rightChildren={ + + 3 + + } + > + Onboarding + + choose("spec")} + > + Spec draft + + + + + + choose("settings")} + > + Settings + + + ); } diff --git a/mobile/src/components/sidebar/Sidebar.tsx b/mobile/src/components/sidebar/Sidebar.tsx new file mode 100644 index 0000000000..bf9f24a109 --- /dev/null +++ b/mobile/src/components/sidebar/Sidebar.tsx @@ -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 ( + + + {/* Tinted backdrop (web `bg-mask-03`; the ~1px backdrop-blur is dropped — not + a mobile token). Tap to close. */} + + + + + + + {children} + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + {logoEl != null && ( + + {logoEl} + setFolded(true)} + hitSlop={8} + className="rounded-08 p-1 active:bg-background-tint-03" + > + + + + )} + {children != null && {children}} + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Footer — pinned content below the scroll area +// --------------------------------------------------------------------------- + +interface SidebarFooterProps { + children?: React.ReactNode; +} + +function SidebarFooter({ children }: SidebarFooterProps) { + return {children}; +} + +// --------------------------------------------------------------------------- +// 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 ( + + {title ? ( + + + + {title} + + + {action != null && {action}} + + ) : ( + + )} + {children} + + ); +} + +// --------------------------------------------------------------------------- +// 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, +}; diff --git a/mobile/src/components/sidebar/SidebarProvider.tsx b/mobile/src/components/sidebar/SidebarProvider.tsx new file mode 100644 index 0000000000..05d0ff65d7 --- /dev/null +++ b/mobile/src/components/sidebar/SidebarProvider.tsx @@ -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>; +} + +const SidebarStateContext = createContext( + 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 ( + + {children} + + ); +} + +export function useSidebar(): SidebarStateContextType { + const context = useContext(SidebarStateContext); + if (context === undefined) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + return context; +} diff --git a/mobile/src/components/sidebar/SidebarTab.tsx b/mobile/src/components/sidebar/SidebarTab.tsx new file mode 100644 index 0000000000..33c5857ea7 --- /dev/null +++ b/mobile/src/components/sidebar/SidebarTab.tsx @@ -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 ( + + {/* 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 ? ( + + ) : icon ? ( + + + + ) : null} + + {!folded && ( + <> + {typeof children === "string" ? ( + + {children} + + ) : ( + {children} + )} + + {rightChildren && ( + + {rightChildren} + + )} + + )} + + ); +} + +export { SidebarTab, type SidebarTabProps }; diff --git a/mobile/src/components/sidebar/index.ts b/mobile/src/components/sidebar/index.ts new file mode 100644 index 0000000000..61262803f7 --- /dev/null +++ b/mobile/src/components/sidebar/index.ts @@ -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"; diff --git a/mobile/src/components/sidebar/interfaces.ts b/mobile/src/components/sidebar/interfaces.ts new file mode 100644 index 0000000000..e77bb04b83 --- /dev/null +++ b/mobile/src/components/sidebar/interfaces.ts @@ -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; diff --git a/mobile/src/components/ui/icon.tsx b/mobile/src/components/ui/icon.tsx new file mode 100644 index 0000000000..1d1d57f0cd --- /dev/null +++ b/mobile/src/components/ui/icon.tsx @@ -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 ; +} + +cssInterop(IconImpl, { + className: { + target: "style", + nativeStyleToProp: { color: true }, + }, +}); + +/** + * @example + * import SvgSearch from "@/icons/search"; + * + */ +function Icon({ as, className, size = 16, ...props }: IconWrapperProps) { + return ( + + ); +} + +export { Icon }; diff --git a/mobile/src/components/ui/separator.tsx b/mobile/src/components/ui/separator.tsx new file mode 100644 index 0000000000..c407ed92a2 --- /dev/null +++ b/mobile/src/components/ui/separator.tsx @@ -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) { + return ( + + ); +} + +export { Separator }; diff --git a/mobile/src/components/ui/text.tsx b/mobile/src/components/ui/text.tsx new file mode 100644 index 0000000000..0431e62cbd --- /dev/null +++ b/mobile/src/components/ui/text.tsx @@ -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 { + /** 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 ( + + ); +} + +export { Text, type TextFont }; diff --git a/mobile/src/icons/bubble-text.tsx b/mobile/src/icons/bubble-text.tsx new file mode 100644 index 0000000000..314c1cf761 --- /dev/null +++ b/mobile/src/icons/bubble-text.tsx @@ -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) => ( + + + +); + +export default SvgBubbleText; diff --git a/mobile/src/icons/folder.tsx b/mobile/src/icons/folder.tsx new file mode 100644 index 0000000000..75df61c929 --- /dev/null +++ b/mobile/src/icons/folder.tsx @@ -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) => ( + + + +); + +export default SvgFolder; diff --git a/mobile/src/icons/plus.tsx b/mobile/src/icons/plus.tsx new file mode 100644 index 0000000000..4fdd4ecd88 --- /dev/null +++ b/mobile/src/icons/plus.tsx @@ -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) => ( + + + +); + +export default SvgPlus; diff --git a/mobile/src/icons/search.tsx b/mobile/src/icons/search.tsx new file mode 100644 index 0000000000..04d0b72ce0 --- /dev/null +++ b/mobile/src/icons/search.tsx @@ -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) => ( + + + +); + +export default SvgSearch; diff --git a/mobile/src/icons/settings.tsx b/mobile/src/icons/settings.tsx new file mode 100644 index 0000000000..51b4b60aec --- /dev/null +++ b/mobile/src/icons/settings.tsx @@ -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) => ( + + + + +); + +export default SvgSettings; diff --git a/mobile/src/icons/sidebar.tsx b/mobile/src/icons/sidebar.tsx new file mode 100644 index 0000000000..2fa9c1f9fb --- /dev/null +++ b/mobile/src/icons/sidebar.tsx @@ -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) => ( + + + +); + +export default SvgSidebar; diff --git a/mobile/src/icons/types.ts b/mobile/src/icons/types.ts new file mode 100644 index 0000000000..fc5eb1e26c --- /dev/null +++ b/mobile/src/icons/types.ts @@ -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; diff --git a/mobile/src/lib/utils.ts b/mobile/src/lib/utils.ts new file mode 100644 index 0000000000..eb4becbcc8 --- /dev/null +++ b/mobile/src/lib/utils.ts @@ -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)); +} diff --git a/web/lib/shared/style-dictionary.config.mjs b/web/lib/shared/style-dictionary.config.mjs index 546ca9311e..f6192b41b9 100644 --- a/web/lib/shared/style-dictionary.config.mjs +++ b/web/lib/shared/style-dictionary.config.mjs @@ -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;\n" + - "export declare const varsDark: Record;\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;\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;\n" + + "export declare const varsDark: Record;\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;\n" + ); + }, }); // FORMAT -> native.js (mobile, ESM). The RN analog of web's `.dark`: two fully