.env.example DELETED
@@ -1,35 +0,0 @@
1
- # Reachy Mini Website server env vars
2
- #
3
- # Copy this file to `.env` and fill in the values for local dev.
4
- # In production (HF Space), set these from the Space's "Settings →
5
- # Variables and secrets" panel, NOT from a committed `.env`.
6
- # (`.env` is gitignored.)
7
-
8
- # -----------------------------------------------------------------------------
9
- # Server
10
- # -----------------------------------------------------------------------------
11
- # Port the Express server listens on. Defaults to 7860 (HF Space convention).
12
- # PORT=7860
13
-
14
- # -----------------------------------------------------------------------------
15
- # OAuth (used by /api/oauth-config and the in-iframe sign-in flow)
16
- # -----------------------------------------------------------------------------
17
- # Set in the Space when `hf_oauth: true` is in README.md.
18
- # OAUTH_CLIENT_ID=
19
- # OAUTH_SCOPES=openid profile
20
-
21
- # -----------------------------------------------------------------------------
22
- # HF Inference Providers (used by /api/js-apps category inference)
23
- # -----------------------------------------------------------------------------
24
- # Required for category inference. A standard READ token is enough -
25
- # Inference Providers access is on by default for FREE/PRO tokens.
26
- # Without this, /api/js-apps still works but every entry will have
27
- # `categories: null` (the route logs a warning at startup).
28
- HF_TOKEN=
29
-
30
- # Dataset where the inferred-categories cache is persisted.
31
- # Defaults to `tfrere/reachy-mini-app-categories` (per-user namespace,
32
- # auto-created on first commit). Override to e.g.
33
- # `pollen-robotics/reachy-mini-app-categories` once the org dataset
34
- # exists and the HF_TOKEN has write access to it.
35
- # HF_CATEGORIES_DATASET=tfrere/reachy-mini-app-categories
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -22,5 +22,3 @@ dist-ssr
22
  *.njsproj
23
  *.sln
24
  *.sw?
25
-
26
- .env
 
22
  *.njsproj
23
  *.sln
24
  *.sw?
 
 
README.md CHANGED
@@ -5,7 +5,8 @@ colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
  pinned: true
8
- hf_oauth: true
 
9
  thumbnail: >-
10
  https://cdn-uploads.huggingface.co/production/uploads/671faa3a541a76b548647676/XWNDlOu0R4fHXR0kCW3Wd.png
11
  short_description: All about Reachy Mini, from building to getting started
 
5
  colorTo: purple
6
  sdk: docker
7
  pinned: true
8
+ tags:
9
+ - reachy_mini
10
  thumbnail: >-
11
  https://cdn-uploads.huggingface.co/production/uploads/671faa3a541a76b548647676/XWNDlOu0R4fHXR0kCW3Wd.png
12
  short_description: All about Reachy Mini, from building to getting started
docs/APP_ICON_CONVENTION.md DELETED
@@ -1,113 +0,0 @@
1
- # App icon convention
2
-
3
- > Status: convention v1
4
- > Audience: authors shipping a Reachy Mini app to the Hugging Face Hub
5
- > Implemented by: `reachy-mini-website` catalog server (this repo) +
6
- > `reachy_mini_mobile_app`, `reachy_mini_desktop_app`
7
- > Source of truth: `server/index.js` → `findIconUrl()`
8
-
9
- This document specifies how a Reachy Mini app declares a custom icon.
10
- Apps that don't follow it keep working - the surface falls back to the
11
- front-matter `emoji:` glyph, which is the existing behaviour.
12
-
13
- ---
14
-
15
- ## 1. The convention in three lines
16
-
17
- To ship a custom icon for your Reachy Mini app:
18
-
19
- 1. Commit `icon.svg` (preferred) **or** `icon.png` at the root of your
20
- Hugging Face Space repository.
21
- 2. That's it. Within ~5 minutes (the catalog cache TTL) the mobile
22
- shell, the desktop app and the website surface your icon
23
- automatically, replacing the README front-matter emoji.
24
- 3. If both files are present, `icon.svg` wins.
25
-
26
- No README change required. No tag to add. No PR to file against this
27
- repo. The catalog server scans the file list once per refresh and
28
- publishes a resolved URL on the app entry; every client consumes it.
29
-
30
- ---
31
-
32
- ## 2. Why a file convention and not `cardData.thumbnail`
33
-
34
- HF Spaces support a `thumbnail:` field in README front-matter, but:
35
-
36
- - `thumbnail` is full-bleed marketing artwork (typically 1200x630),
37
- not a square avatar. Scaling it to a 22 px or 44 px tile produces
38
- muddy thumbnails.
39
- - We want app authors to ship a dedicated, optimised glyph they
40
- control without learning the HF metadata schema.
41
- - SVG support means the icon scales cleanly across every mount point
42
- (rail tile, pinned grid, iframe header) from a single asset.
43
-
44
- `thumbnail:` keeps its existing role (banner artwork on the Space's
45
- HF page) and is not consulted by this resolution path.
46
-
47
- ---
48
-
49
- ## 3. Format & dimension recommendations
50
-
51
- | Property | Recommended | Hard requirement |
52
- |----------|-------------|------------------|
53
- | Format | `icon.svg` (vector) | `icon.svg` or `icon.png` |
54
- | Aspect ratio | 1:1 (square) | Renderers crop with `object-fit: contain`, but non-square icons render with letterboxing - prefer a true square |
55
- | Min PNG size | 256x256 | None enforced. PNGs below 64x64 will look soft on the pinned grid (44 px on retina ≈ 88 effective px) |
56
- | Background | Transparent OR solid colour | None - your call. Renderers don't add their own plate, so an icon with no background renders directly on the tile colour |
57
- | Padding | Bake ~10% inner padding into the asset | None - but icons that bleed edge-to-edge will touch the tile's rounded corners |
58
- | Light/dark variants | Single asset that works on both | None - if you must, ship two SVGs and use `prefers-color-scheme` inside the SVG via CSS |
59
-
60
- ### Style notes
61
-
62
- - **Iconic, not photographic.** A solid filled silhouette reads at
63
- 22 px; a screenshot doesn't.
64
- - **High contrast against `background.paper`.** The mobile app paints
65
- the tile background with the surface colour (very light grey on
66
- light, near-black on dark). A pure white icon disappears on light.
67
- - **No drop shadow** baked into the asset. The renderer doesn't add
68
- one either, and a baked shadow won't scale across sizes.
69
-
70
- ---
71
-
72
- ## 4. How resolution works (for the curious)
73
-
74
- 1. The catalog server calls
75
- `https://huggingface.co/api/spaces?filter=reachy_mini&full=true`.
76
- With `full=true`, the HF Hub returns `siblings: [{ rfilename: ... }]`
77
- for every Space - the complete file list.
78
- 2. For each app, `findIconUrl()` (in `server/index.js`) scans the
79
- list for root-level filenames matching `ICON_CANDIDATES` in order
80
- (`icon.svg` → `icon.png`).
81
- 3. The first match becomes:
82
-
83
- ```
84
- https://huggingface.co/spaces/<author>/<repo>/resolve/main/<filename>
85
- ```
86
-
87
- `resolve/main/` (not `raw/main/`) so LFS pointers follow through
88
- transparently and the `Content-Type` is set from the extension,
89
- which `<img>` needs.
90
- 4. The URL is published on the app entry as a top-level `iconUrl`
91
- field. `null` when neither candidate exists.
92
- 5. Clients (`reachy_mini_mobile_app`, `reachy_mini_desktop_app`) read
93
- `iconUrl` and render an `<img>` when present, falling back to the
94
- front-matter emoji otherwise. A runtime image load failure
95
- re-falls-back to the emoji without a refresh.
96
-
97
- The whole resolution path is server-side, behind the 5-minute catalog
98
- cache. Adding 100 more apps adds zero per-client probes.
99
-
100
- ---
101
-
102
- ## 5. Adding new icon formats
103
-
104
- If you need to support a new format (say, `icon.webp`), edit
105
- `ICON_CANDIDATES` in `server/index.js`:
106
-
107
- ```js
108
- const ICON_CANDIDATES = ['icon.svg', 'icon.png', 'icon.webp'];
109
- ```
110
-
111
- Order matters - the first hit wins, so put the preferred format first.
112
- Bumping the catalog cache (POST `/api/js-apps/refresh-categories` or
113
- just wait 5 minutes) picks up the new resolution rule.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html CHANGED
@@ -1,14 +1,6 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
- <!-- Google Tag Manager -->
5
- <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
6
- new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
7
- j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
8
- 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
9
- })(window,document,'script','dataLayer','GTM-WKKZHMJJ');</script>
10
- <!-- End Google Tag Manager -->
11
-
12
  <!-- Privacy-friendly analytics by Plausible -->
13
  <script async src="https://plausible.io/js/pa-4zj-sGpYxM8ggvg7a-Sjo.js"></script>
14
  <script>
@@ -22,11 +14,6 @@
22
  <title>Reachy Mini - Open Source Companion Robot</title>
23
  </head>
24
  <body>
25
- <!-- Google Tag Manager (noscript) -->
26
- <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WKKZHMJJ"
27
- height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
28
- <!-- End Google Tag Manager (noscript) -->
29
-
30
  <div id="root"></div>
31
  <script type="module" src="/src/main.jsx"></script>
32
  </body>
 
1
  <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
 
 
 
 
 
 
 
 
4
  <!-- Privacy-friendly analytics by Plausible -->
5
  <script async src="https://plausible.io/js/pa-4zj-sGpYxM8ggvg7a-Sjo.js"></script>
6
  <script>
 
14
  <title>Reachy Mini - Open Source Companion Robot</title>
15
  </head>
16
  <body>
 
 
 
 
 
17
  <div id="root"></div>
18
  <script type="module" src="/src/main.jsx"></script>
19
  </body>
package-lock.json CHANGED
@@ -10,11 +10,9 @@
10
  "dependencies": {
11
  "@emotion/react": "^11.14.0",
12
  "@emotion/styled": "^11.14.1",
13
- "@huggingface/hub": "^2.8.1",
14
  "@mui/icons-material": "^7.3.6",
15
  "@mui/material": "^7.3.6",
16
  "@react-spring/web": "^10.0.3",
17
- "express": "^4.21.2",
18
  "framer-motion": "^12.23.26",
19
  "highlight.js": "^11.11.1",
20
  "react": "^19.2.0",
@@ -1072,30 +1070,6 @@
1072
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1073
  }
1074
  },
1075
- "node_modules/@huggingface/hub": {
1076
- "version": "2.8.1",
1077
- "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.8.1.tgz",
1078
- "integrity": "sha512-VAsXdMiIHPteXQJhrwaBEiePTWiJ0zBSymHdnX4J+AijjNN0h3RzGfkKemXcu75gu/TmRLFY9l8+2Tkdmpis0w==",
1079
- "license": "MIT",
1080
- "dependencies": {
1081
- "@huggingface/tasks": "^0.19.82"
1082
- },
1083
- "bin": {
1084
- "hfjs": "dist/cli.js"
1085
- },
1086
- "engines": {
1087
- "node": ">=18"
1088
- },
1089
- "optionalDependencies": {
1090
- "cli-progress": "^3.12.0"
1091
- }
1092
- },
1093
- "node_modules/@huggingface/tasks": {
1094
- "version": "0.19.83",
1095
- "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.19.83.tgz",
1096
- "integrity": "sha512-nBt3S6x+MWUTmfey1drQZRMuEopEbz2aEMUsoddfpCuzIYAMCsJDX7xeNuJnzvbVGis3gXXCRcLHVhFtHaaiyA==",
1097
- "license": "MIT"
1098
- },
1099
  "node_modules/@humanfs/core": {
1100
  "version": "0.19.1",
1101
  "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2004,19 +1978,6 @@
2004
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
2005
  }
2006
  },
2007
- "node_modules/accepts": {
2008
- "version": "1.3.8",
2009
- "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
2010
- "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
2011
- "license": "MIT",
2012
- "dependencies": {
2013
- "mime-types": "~2.1.34",
2014
- "negotiator": "0.6.3"
2015
- },
2016
- "engines": {
2017
- "node": ">= 0.6"
2018
- }
2019
- },
2020
  "node_modules/acorn": {
2021
  "version": "8.15.0",
2022
  "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2058,16 +2019,6 @@
2058
  "url": "https://github.com/sponsors/epoberezkin"
2059
  }
2060
  },
2061
- "node_modules/ansi-regex": {
2062
- "version": "5.0.1",
2063
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
2064
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
2065
- "license": "MIT",
2066
- "optional": true,
2067
- "engines": {
2068
- "node": ">=8"
2069
- }
2070
- },
2071
  "node_modules/ansi-styles": {
2072
  "version": "4.3.0",
2073
  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2091,12 +2042,6 @@
2091
  "dev": true,
2092
  "license": "Python-2.0"
2093
  },
2094
- "node_modules/array-flatten": {
2095
- "version": "1.1.1",
2096
- "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
2097
- "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
2098
- "license": "MIT"
2099
- },
2100
  "node_modules/babel-plugin-macros": {
2101
  "version": "3.1.0",
2102
  "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
@@ -2138,45 +2083,6 @@
2138
  "baseline-browser-mapping": "dist/cli.js"
2139
  }
2140
  },
2141
- "node_modules/body-parser": {
2142
- "version": "1.20.4",
2143
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
2144
- "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
2145
- "license": "MIT",
2146
- "dependencies": {
2147
- "bytes": "~3.1.2",
2148
- "content-type": "~1.0.5",
2149
- "debug": "2.6.9",
2150
- "depd": "2.0.0",
2151
- "destroy": "~1.2.0",
2152
- "http-errors": "~2.0.1",
2153
- "iconv-lite": "~0.4.24",
2154
- "on-finished": "~2.4.1",
2155
- "qs": "~6.14.0",
2156
- "raw-body": "~2.5.3",
2157
- "type-is": "~1.6.18",
2158
- "unpipe": "~1.0.0"
2159
- },
2160
- "engines": {
2161
- "node": ">= 0.8",
2162
- "npm": "1.2.8000 || >= 1.4.16"
2163
- }
2164
- },
2165
- "node_modules/body-parser/node_modules/debug": {
2166
- "version": "2.6.9",
2167
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
2168
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
2169
- "license": "MIT",
2170
- "dependencies": {
2171
- "ms": "2.0.0"
2172
- }
2173
- },
2174
- "node_modules/body-parser/node_modules/ms": {
2175
- "version": "2.0.0",
2176
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
2177
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
2178
- "license": "MIT"
2179
- },
2180
  "node_modules/brace-expansion": {
2181
  "version": "1.1.12",
2182
  "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2223,44 +2129,6 @@
2223
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
2224
  }
2225
  },
2226
- "node_modules/bytes": {
2227
- "version": "3.1.2",
2228
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
2229
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
2230
- "license": "MIT",
2231
- "engines": {
2232
- "node": ">= 0.8"
2233
- }
2234
- },
2235
- "node_modules/call-bind-apply-helpers": {
2236
- "version": "1.0.2",
2237
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
2238
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
2239
- "license": "MIT",
2240
- "dependencies": {
2241
- "es-errors": "^1.3.0",
2242
- "function-bind": "^1.1.2"
2243
- },
2244
- "engines": {
2245
- "node": ">= 0.4"
2246
- }
2247
- },
2248
- "node_modules/call-bound": {
2249
- "version": "1.0.4",
2250
- "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
2251
- "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
2252
- "license": "MIT",
2253
- "dependencies": {
2254
- "call-bind-apply-helpers": "^1.0.2",
2255
- "get-intrinsic": "^1.3.0"
2256
- },
2257
- "engines": {
2258
- "node": ">= 0.4"
2259
- },
2260
- "funding": {
2261
- "url": "https://github.com/sponsors/ljharb"
2262
- }
2263
- },
2264
  "node_modules/callsites": {
2265
  "version": "3.1.0",
2266
  "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2353,19 +2221,6 @@
2353
  "url": "https://github.com/sponsors/wooorm"
2354
  }
2355
  },
2356
- "node_modules/cli-progress": {
2357
- "version": "3.12.0",
2358
- "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
2359
- "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
2360
- "license": "MIT",
2361
- "optional": true,
2362
- "dependencies": {
2363
- "string-width": "^4.2.3"
2364
- },
2365
- "engines": {
2366
- "node": ">=4"
2367
- }
2368
- },
2369
  "node_modules/clsx": {
2370
  "version": "2.1.1",
2371
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2411,27 +2266,6 @@
2411
  "dev": true,
2412
  "license": "MIT"
2413
  },
2414
- "node_modules/content-disposition": {
2415
- "version": "0.5.4",
2416
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
2417
- "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
2418
- "license": "MIT",
2419
- "dependencies": {
2420
- "safe-buffer": "5.2.1"
2421
- },
2422
- "engines": {
2423
- "node": ">= 0.6"
2424
- }
2425
- },
2426
- "node_modules/content-type": {
2427
- "version": "1.0.5",
2428
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
2429
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
2430
- "license": "MIT",
2431
- "engines": {
2432
- "node": ">= 0.6"
2433
- }
2434
- },
2435
  "node_modules/convert-source-map": {
2436
  "version": "1.9.0",
2437
  "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -2451,12 +2285,6 @@
2451
  "url": "https://opencollective.com/express"
2452
  }
2453
  },
2454
- "node_modules/cookie-signature": {
2455
- "version": "1.0.7",
2456
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
2457
- "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
2458
- "license": "MIT"
2459
- },
2460
  "node_modules/cosmiconfig": {
2461
  "version": "7.1.0",
2462
  "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -2539,15 +2367,6 @@
2539
  "dev": true,
2540
  "license": "MIT"
2541
  },
2542
- "node_modules/depd": {
2543
- "version": "2.0.0",
2544
- "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
2545
- "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
2546
- "license": "MIT",
2547
- "engines": {
2548
- "node": ">= 0.8"
2549
- }
2550
- },
2551
  "node_modules/dequal": {
2552
  "version": "2.0.3",
2553
  "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2556,16 +2375,6 @@
2556
  "node": ">=6"
2557
  }
2558
  },
2559
- "node_modules/destroy": {
2560
- "version": "1.2.0",
2561
- "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
2562
- "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
2563
- "license": "MIT",
2564
- "engines": {
2565
- "node": ">= 0.8",
2566
- "npm": "1.2.8000 || >= 1.4.16"
2567
- }
2568
- },
2569
  "node_modules/devlop": {
2570
  "version": "1.1.0",
2571
  "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -2588,26 +2397,6 @@
2588
  "csstype": "^3.0.2"
2589
  }
2590
  },
2591
- "node_modules/dunder-proto": {
2592
- "version": "1.0.1",
2593
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
2594
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
2595
- "license": "MIT",
2596
- "dependencies": {
2597
- "call-bind-apply-helpers": "^1.0.1",
2598
- "es-errors": "^1.3.0",
2599
- "gopd": "^1.2.0"
2600
- },
2601
- "engines": {
2602
- "node": ">= 0.4"
2603
- }
2604
- },
2605
- "node_modules/ee-first": {
2606
- "version": "1.1.1",
2607
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
2608
- "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
2609
- "license": "MIT"
2610
- },
2611
  "node_modules/electron-to-chromium": {
2612
  "version": "1.5.267",
2613
  "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -2615,22 +2404,6 @@
2615
  "dev": true,
2616
  "license": "ISC"
2617
  },
2618
- "node_modules/emoji-regex": {
2619
- "version": "8.0.0",
2620
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
2621
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
2622
- "license": "MIT",
2623
- "optional": true
2624
- },
2625
- "node_modules/encodeurl": {
2626
- "version": "2.0.0",
2627
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
2628
- "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
2629
- "license": "MIT",
2630
- "engines": {
2631
- "node": ">= 0.8"
2632
- }
2633
- },
2634
  "node_modules/error-ex": {
2635
  "version": "1.3.4",
2636
  "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -2640,36 +2413,6 @@
2640
  "is-arrayish": "^0.2.1"
2641
  }
2642
  },
2643
- "node_modules/es-define-property": {
2644
- "version": "1.0.1",
2645
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
2646
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
2647
- "license": "MIT",
2648
- "engines": {
2649
- "node": ">= 0.4"
2650
- }
2651
- },
2652
- "node_modules/es-errors": {
2653
- "version": "1.3.0",
2654
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
2655
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
2656
- "license": "MIT",
2657
- "engines": {
2658
- "node": ">= 0.4"
2659
- }
2660
- },
2661
- "node_modules/es-object-atoms": {
2662
- "version": "1.1.1",
2663
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
2664
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
2665
- "license": "MIT",
2666
- "dependencies": {
2667
- "es-errors": "^1.3.0"
2668
- },
2669
- "engines": {
2670
- "node": ">= 0.4"
2671
- }
2672
- },
2673
  "node_modules/esbuild": {
2674
  "version": "0.25.12",
2675
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2722,12 +2465,6 @@
2722
  "node": ">=6"
2723
  }
2724
  },
2725
- "node_modules/escape-html": {
2726
- "version": "1.0.3",
2727
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
2728
- "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
2729
- "license": "MIT"
2730
- },
2731
  "node_modules/escape-string-regexp": {
2732
  "version": "4.0.0",
2733
  "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2934,85 +2671,6 @@
2934
  "node": ">=0.10.0"
2935
  }
2936
  },
2937
- "node_modules/etag": {
2938
- "version": "1.8.1",
2939
- "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
2940
- "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
2941
- "license": "MIT",
2942
- "engines": {
2943
- "node": ">= 0.6"
2944
- }
2945
- },
2946
- "node_modules/express": {
2947
- "version": "4.22.1",
2948
- "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
2949
- "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
2950
- "license": "MIT",
2951
- "dependencies": {
2952
- "accepts": "~1.3.8",
2953
- "array-flatten": "1.1.1",
2954
- "body-parser": "~1.20.3",
2955
- "content-disposition": "~0.5.4",
2956
- "content-type": "~1.0.4",
2957
- "cookie": "~0.7.1",
2958
- "cookie-signature": "~1.0.6",
2959
- "debug": "2.6.9",
2960
- "depd": "2.0.0",
2961
- "encodeurl": "~2.0.0",
2962
- "escape-html": "~1.0.3",
2963
- "etag": "~1.8.1",
2964
- "finalhandler": "~1.3.1",
2965
- "fresh": "~0.5.2",
2966
- "http-errors": "~2.0.0",
2967
- "merge-descriptors": "1.0.3",
2968
- "methods": "~1.1.2",
2969
- "on-finished": "~2.4.1",
2970
- "parseurl": "~1.3.3",
2971
- "path-to-regexp": "~0.1.12",
2972
- "proxy-addr": "~2.0.7",
2973
- "qs": "~6.14.0",
2974
- "range-parser": "~1.2.1",
2975
- "safe-buffer": "5.2.1",
2976
- "send": "~0.19.0",
2977
- "serve-static": "~1.16.2",
2978
- "setprototypeof": "1.2.0",
2979
- "statuses": "~2.0.1",
2980
- "type-is": "~1.6.18",
2981
- "utils-merge": "1.0.1",
2982
- "vary": "~1.1.2"
2983
- },
2984
- "engines": {
2985
- "node": ">= 0.10.0"
2986
- },
2987
- "funding": {
2988
- "type": "opencollective",
2989
- "url": "https://opencollective.com/express"
2990
- }
2991
- },
2992
- "node_modules/express/node_modules/cookie": {
2993
- "version": "0.7.2",
2994
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
2995
- "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
2996
- "license": "MIT",
2997
- "engines": {
2998
- "node": ">= 0.6"
2999
- }
3000
- },
3001
- "node_modules/express/node_modules/debug": {
3002
- "version": "2.6.9",
3003
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
3004
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
3005
- "license": "MIT",
3006
- "dependencies": {
3007
- "ms": "2.0.0"
3008
- }
3009
- },
3010
- "node_modules/express/node_modules/ms": {
3011
- "version": "2.0.0",
3012
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
3013
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
3014
- "license": "MIT"
3015
- },
3016
  "node_modules/extend": {
3017
  "version": "3.0.2",
3018
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -3070,39 +2728,6 @@
3070
  "node": ">=16.0.0"
3071
  }
3072
  },
3073
- "node_modules/finalhandler": {
3074
- "version": "1.3.2",
3075
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
3076
- "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
3077
- "license": "MIT",
3078
- "dependencies": {
3079
- "debug": "2.6.9",
3080
- "encodeurl": "~2.0.0",
3081
- "escape-html": "~1.0.3",
3082
- "on-finished": "~2.4.1",
3083
- "parseurl": "~1.3.3",
3084
- "statuses": "~2.0.2",
3085
- "unpipe": "~1.0.0"
3086
- },
3087
- "engines": {
3088
- "node": ">= 0.8"
3089
- }
3090
- },
3091
- "node_modules/finalhandler/node_modules/debug": {
3092
- "version": "2.6.9",
3093
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
3094
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
3095
- "license": "MIT",
3096
- "dependencies": {
3097
- "ms": "2.0.0"
3098
- }
3099
- },
3100
- "node_modules/finalhandler/node_modules/ms": {
3101
- "version": "2.0.0",
3102
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
3103
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
3104
- "license": "MIT"
3105
- },
3106
  "node_modules/find-root": {
3107
  "version": "1.1.0",
3108
  "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -3147,15 +2772,6 @@
3147
  "dev": true,
3148
  "license": "ISC"
3149
  },
3150
- "node_modules/forwarded": {
3151
- "version": "0.2.0",
3152
- "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
3153
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
3154
- "license": "MIT",
3155
- "engines": {
3156
- "node": ">= 0.6"
3157
- }
3158
- },
3159
  "node_modules/framer-motion": {
3160
  "version": "12.23.26",
3161
  "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
@@ -3183,15 +2799,6 @@
3183
  }
3184
  }
3185
  },
3186
- "node_modules/fresh": {
3187
- "version": "0.5.2",
3188
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
3189
- "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
3190
- "license": "MIT",
3191
- "engines": {
3192
- "node": ">= 0.6"
3193
- }
3194
- },
3195
  "node_modules/fsevents": {
3196
  "version": "2.3.3",
3197
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3225,43 +2832,6 @@
3225
  "node": ">=6.9.0"
3226
  }
3227
  },
3228
- "node_modules/get-intrinsic": {
3229
- "version": "1.3.0",
3230
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
3231
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
3232
- "license": "MIT",
3233
- "dependencies": {
3234
- "call-bind-apply-helpers": "^1.0.2",
3235
- "es-define-property": "^1.0.1",
3236
- "es-errors": "^1.3.0",
3237
- "es-object-atoms": "^1.1.1",
3238
- "function-bind": "^1.1.2",
3239
- "get-proto": "^1.0.1",
3240
- "gopd": "^1.2.0",
3241
- "has-symbols": "^1.1.0",
3242
- "hasown": "^2.0.2",
3243
- "math-intrinsics": "^1.1.0"
3244
- },
3245
- "engines": {
3246
- "node": ">= 0.4"
3247
- },
3248
- "funding": {
3249
- "url": "https://github.com/sponsors/ljharb"
3250
- }
3251
- },
3252
- "node_modules/get-proto": {
3253
- "version": "1.0.1",
3254
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
3255
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
3256
- "license": "MIT",
3257
- "dependencies": {
3258
- "dunder-proto": "^1.0.1",
3259
- "es-object-atoms": "^1.0.0"
3260
- },
3261
- "engines": {
3262
- "node": ">= 0.4"
3263
- }
3264
- },
3265
  "node_modules/glob-parent": {
3266
  "version": "6.0.2",
3267
  "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3288,18 +2858,6 @@
3288
  "url": "https://github.com/sponsors/sindresorhus"
3289
  }
3290
  },
3291
- "node_modules/gopd": {
3292
- "version": "1.2.0",
3293
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
3294
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
3295
- "license": "MIT",
3296
- "engines": {
3297
- "node": ">= 0.4"
3298
- },
3299
- "funding": {
3300
- "url": "https://github.com/sponsors/ljharb"
3301
- }
3302
- },
3303
  "node_modules/has-flag": {
3304
  "version": "4.0.0",
3305
  "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -3310,18 +2868,6 @@
3310
  "node": ">=8"
3311
  }
3312
  },
3313
- "node_modules/has-symbols": {
3314
- "version": "1.1.0",
3315
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
3316
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
3317
- "license": "MIT",
3318
- "engines": {
3319
- "node": ">= 0.4"
3320
- },
3321
- "funding": {
3322
- "url": "https://github.com/sponsors/ljharb"
3323
- }
3324
- },
3325
  "node_modules/hasown": {
3326
  "version": "2.0.2",
3327
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -3443,38 +2989,6 @@
3443
  "url": "https://opencollective.com/unified"
3444
  }
3445
  },
3446
- "node_modules/http-errors": {
3447
- "version": "2.0.1",
3448
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
3449
- "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
3450
- "license": "MIT",
3451
- "dependencies": {
3452
- "depd": "~2.0.0",
3453
- "inherits": "~2.0.4",
3454
- "setprototypeof": "~1.2.0",
3455
- "statuses": "~2.0.2",
3456
- "toidentifier": "~1.0.1"
3457
- },
3458
- "engines": {
3459
- "node": ">= 0.8"
3460
- },
3461
- "funding": {
3462
- "type": "opencollective",
3463
- "url": "https://opencollective.com/express"
3464
- }
3465
- },
3466
- "node_modules/iconv-lite": {
3467
- "version": "0.4.24",
3468
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
3469
- "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
3470
- "license": "MIT",
3471
- "dependencies": {
3472
- "safer-buffer": ">= 2.1.2 < 3"
3473
- },
3474
- "engines": {
3475
- "node": ">=0.10.0"
3476
- }
3477
- },
3478
  "node_modules/ignore": {
3479
  "version": "5.3.2",
3480
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3511,26 +3025,11 @@
3511
  "node": ">=0.8.19"
3512
  }
3513
  },
3514
- "node_modules/inherits": {
3515
- "version": "2.0.4",
3516
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
3517
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
3518
- "license": "ISC"
3519
- },
3520
  "node_modules/inline-style-parser": {
3521
  "version": "0.2.7",
3522
  "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
3523
  "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
3524
  },
3525
- "node_modules/ipaddr.js": {
3526
- "version": "1.9.1",
3527
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
3528
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
3529
- "license": "MIT",
3530
- "engines": {
3531
- "node": ">= 0.10"
3532
- }
3533
- },
3534
  "node_modules/is-alphabetical": {
3535
  "version": "2.0.1",
3536
  "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -3593,16 +3092,6 @@
3593
  "node": ">=0.10.0"
3594
  }
3595
  },
3596
- "node_modules/is-fullwidth-code-point": {
3597
- "version": "3.0.0",
3598
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
3599
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
3600
- "license": "MIT",
3601
- "optional": true,
3602
- "engines": {
3603
- "node": ">=8"
3604
- }
3605
- },
3606
  "node_modules/is-glob": {
3607
  "version": "4.0.3",
3608
  "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3821,15 +3310,6 @@
3821
  "url": "https://github.com/sponsors/wooorm"
3822
  }
3823
  },
3824
- "node_modules/math-intrinsics": {
3825
- "version": "1.1.0",
3826
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
3827
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
3828
- "license": "MIT",
3829
- "engines": {
3830
- "node": ">= 0.4"
3831
- }
3832
- },
3833
  "node_modules/mdast-util-find-and-replace": {
3834
  "version": "3.0.2",
3835
  "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -4096,33 +3576,6 @@
4096
  "url": "https://opencollective.com/unified"
4097
  }
4098
  },
4099
- "node_modules/media-typer": {
4100
- "version": "0.3.0",
4101
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
4102
- "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
4103
- "license": "MIT",
4104
- "engines": {
4105
- "node": ">= 0.6"
4106
- }
4107
- },
4108
- "node_modules/merge-descriptors": {
4109
- "version": "1.0.3",
4110
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
4111
- "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
4112
- "license": "MIT",
4113
- "funding": {
4114
- "url": "https://github.com/sponsors/sindresorhus"
4115
- }
4116
- },
4117
- "node_modules/methods": {
4118
- "version": "1.1.2",
4119
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
4120
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
4121
- "license": "MIT",
4122
- "engines": {
4123
- "node": ">= 0.6"
4124
- }
4125
- },
4126
  "node_modules/micromark": {
4127
  "version": "4.0.2",
4128
  "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -4658,39 +4111,6 @@
4658
  }
4659
  ]
4660
  },
4661
- "node_modules/mime": {
4662
- "version": "1.6.0",
4663
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
4664
- "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
4665
- "license": "MIT",
4666
- "bin": {
4667
- "mime": "cli.js"
4668
- },
4669
- "engines": {
4670
- "node": ">=4"
4671
- }
4672
- },
4673
- "node_modules/mime-db": {
4674
- "version": "1.52.0",
4675
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
4676
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
4677
- "license": "MIT",
4678
- "engines": {
4679
- "node": ">= 0.6"
4680
- }
4681
- },
4682
- "node_modules/mime-types": {
4683
- "version": "2.1.35",
4684
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
4685
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
4686
- "license": "MIT",
4687
- "dependencies": {
4688
- "mime-db": "1.52.0"
4689
- },
4690
- "engines": {
4691
- "node": ">= 0.6"
4692
- }
4693
- },
4694
  "node_modules/minimatch": {
4695
  "version": "3.1.2",
4696
  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -4751,15 +4171,6 @@
4751
  "dev": true,
4752
  "license": "MIT"
4753
  },
4754
- "node_modules/negotiator": {
4755
- "version": "0.6.3",
4756
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
4757
- "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
4758
- "license": "MIT",
4759
- "engines": {
4760
- "node": ">= 0.6"
4761
- }
4762
- },
4763
  "node_modules/node-releases": {
4764
  "version": "2.0.27",
4765
  "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -4776,30 +4187,6 @@
4776
  "node": ">=0.10.0"
4777
  }
4778
  },
4779
- "node_modules/object-inspect": {
4780
- "version": "1.13.4",
4781
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
4782
- "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
4783
- "license": "MIT",
4784
- "engines": {
4785
- "node": ">= 0.4"
4786
- },
4787
- "funding": {
4788
- "url": "https://github.com/sponsors/ljharb"
4789
- }
4790
- },
4791
- "node_modules/on-finished": {
4792
- "version": "2.4.1",
4793
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
4794
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
4795
- "license": "MIT",
4796
- "dependencies": {
4797
- "ee-first": "1.1.1"
4798
- },
4799
- "engines": {
4800
- "node": ">= 0.8"
4801
- }
4802
- },
4803
  "node_modules/optionator": {
4804
  "version": "0.9.4",
4805
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4903,15 +4290,6 @@
4903
  "url": "https://github.com/sponsors/sindresorhus"
4904
  }
4905
  },
4906
- "node_modules/parseurl": {
4907
- "version": "1.3.3",
4908
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
4909
- "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
4910
- "license": "MIT",
4911
- "engines": {
4912
- "node": ">= 0.8"
4913
- }
4914
- },
4915
  "node_modules/path-exists": {
4916
  "version": "4.0.0",
4917
  "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4938,12 +4316,6 @@
4938
  "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
4939
  "license": "MIT"
4940
  },
4941
- "node_modules/path-to-regexp": {
4942
- "version": "0.1.12",
4943
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
4944
- "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
4945
- "license": "MIT"
4946
- },
4947
  "node_modules/path-type": {
4948
  "version": "4.0.0",
4949
  "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -5032,19 +4404,6 @@
5032
  "url": "https://github.com/sponsors/wooorm"
5033
  }
5034
  },
5035
- "node_modules/proxy-addr": {
5036
- "version": "2.0.7",
5037
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
5038
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
5039
- "license": "MIT",
5040
- "dependencies": {
5041
- "forwarded": "0.2.0",
5042
- "ipaddr.js": "1.9.1"
5043
- },
5044
- "engines": {
5045
- "node": ">= 0.10"
5046
- }
5047
- },
5048
  "node_modules/punycode": {
5049
  "version": "2.3.1",
5050
  "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5055,45 +4414,6 @@
5055
  "node": ">=6"
5056
  }
5057
  },
5058
- "node_modules/qs": {
5059
- "version": "6.14.1",
5060
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
5061
- "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
5062
- "license": "BSD-3-Clause",
5063
- "dependencies": {
5064
- "side-channel": "^1.1.0"
5065
- },
5066
- "engines": {
5067
- "node": ">=0.6"
5068
- },
5069
- "funding": {
5070
- "url": "https://github.com/sponsors/ljharb"
5071
- }
5072
- },
5073
- "node_modules/range-parser": {
5074
- "version": "1.2.1",
5075
- "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
5076
- "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
5077
- "license": "MIT",
5078
- "engines": {
5079
- "node": ">= 0.6"
5080
- }
5081
- },
5082
- "node_modules/raw-body": {
5083
- "version": "2.5.3",
5084
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
5085
- "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
5086
- "license": "MIT",
5087
- "dependencies": {
5088
- "bytes": "~3.1.2",
5089
- "http-errors": "~2.0.1",
5090
- "iconv-lite": "~0.4.24",
5091
- "unpipe": "~1.0.0"
5092
- },
5093
- "engines": {
5094
- "node": ">= 0.8"
5095
- }
5096
- },
5097
  "node_modules/react": {
5098
  "version": "19.2.3",
5099
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
@@ -5362,32 +4682,6 @@
5362
  "fsevents": "~2.3.2"
5363
  }
5364
  },
5365
- "node_modules/safe-buffer": {
5366
- "version": "5.2.1",
5367
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
5368
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
5369
- "funding": [
5370
- {
5371
- "type": "github",
5372
- "url": "https://github.com/sponsors/feross"
5373
- },
5374
- {
5375
- "type": "patreon",
5376
- "url": "https://www.patreon.com/feross"
5377
- },
5378
- {
5379
- "type": "consulting",
5380
- "url": "https://feross.org/support"
5381
- }
5382
- ],
5383
- "license": "MIT"
5384
- },
5385
- "node_modules/safer-buffer": {
5386
- "version": "2.1.2",
5387
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
5388
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
5389
- "license": "MIT"
5390
- },
5391
  "node_modules/scheduler": {
5392
  "version": "0.27.0",
5393
  "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -5404,72 +4698,12 @@
5404
  "semver": "bin/semver.js"
5405
  }
5406
  },
5407
- "node_modules/send": {
5408
- "version": "0.19.2",
5409
- "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
5410
- "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
5411
- "license": "MIT",
5412
- "dependencies": {
5413
- "debug": "2.6.9",
5414
- "depd": "2.0.0",
5415
- "destroy": "1.2.0",
5416
- "encodeurl": "~2.0.0",
5417
- "escape-html": "~1.0.3",
5418
- "etag": "~1.8.1",
5419
- "fresh": "~0.5.2",
5420
- "http-errors": "~2.0.1",
5421
- "mime": "1.6.0",
5422
- "ms": "2.1.3",
5423
- "on-finished": "~2.4.1",
5424
- "range-parser": "~1.2.1",
5425
- "statuses": "~2.0.2"
5426
- },
5427
- "engines": {
5428
- "node": ">= 0.8.0"
5429
- }
5430
- },
5431
- "node_modules/send/node_modules/debug": {
5432
- "version": "2.6.9",
5433
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
5434
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
5435
- "license": "MIT",
5436
- "dependencies": {
5437
- "ms": "2.0.0"
5438
- }
5439
- },
5440
- "node_modules/send/node_modules/debug/node_modules/ms": {
5441
- "version": "2.0.0",
5442
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
5443
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
5444
- "license": "MIT"
5445
- },
5446
- "node_modules/serve-static": {
5447
- "version": "1.16.3",
5448
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
5449
- "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
5450
- "license": "MIT",
5451
- "dependencies": {
5452
- "encodeurl": "~2.0.0",
5453
- "escape-html": "~1.0.3",
5454
- "parseurl": "~1.3.3",
5455
- "send": "~0.19.1"
5456
- },
5457
- "engines": {
5458
- "node": ">= 0.8.0"
5459
- }
5460
- },
5461
  "node_modules/set-cookie-parser": {
5462
  "version": "2.7.2",
5463
  "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
5464
  "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
5465
  "license": "MIT"
5466
  },
5467
- "node_modules/setprototypeof": {
5468
- "version": "1.2.0",
5469
- "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
5470
- "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
5471
- "license": "ISC"
5472
- },
5473
  "node_modules/shebang-command": {
5474
  "version": "2.0.0",
5475
  "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5493,78 +4727,6 @@
5493
  "node": ">=8"
5494
  }
5495
  },
5496
- "node_modules/side-channel": {
5497
- "version": "1.1.0",
5498
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
5499
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
5500
- "license": "MIT",
5501
- "dependencies": {
5502
- "es-errors": "^1.3.0",
5503
- "object-inspect": "^1.13.3",
5504
- "side-channel-list": "^1.0.0",
5505
- "side-channel-map": "^1.0.1",
5506
- "side-channel-weakmap": "^1.0.2"
5507
- },
5508
- "engines": {
5509
- "node": ">= 0.4"
5510
- },
5511
- "funding": {
5512
- "url": "https://github.com/sponsors/ljharb"
5513
- }
5514
- },
5515
- "node_modules/side-channel-list": {
5516
- "version": "1.0.0",
5517
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
5518
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
5519
- "license": "MIT",
5520
- "dependencies": {
5521
- "es-errors": "^1.3.0",
5522
- "object-inspect": "^1.13.3"
5523
- },
5524
- "engines": {
5525
- "node": ">= 0.4"
5526
- },
5527
- "funding": {
5528
- "url": "https://github.com/sponsors/ljharb"
5529
- }
5530
- },
5531
- "node_modules/side-channel-map": {
5532
- "version": "1.0.1",
5533
- "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
5534
- "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
5535
- "license": "MIT",
5536
- "dependencies": {
5537
- "call-bound": "^1.0.2",
5538
- "es-errors": "^1.3.0",
5539
- "get-intrinsic": "^1.2.5",
5540
- "object-inspect": "^1.13.3"
5541
- },
5542
- "engines": {
5543
- "node": ">= 0.4"
5544
- },
5545
- "funding": {
5546
- "url": "https://github.com/sponsors/ljharb"
5547
- }
5548
- },
5549
- "node_modules/side-channel-weakmap": {
5550
- "version": "1.0.2",
5551
- "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
5552
- "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
5553
- "license": "MIT",
5554
- "dependencies": {
5555
- "call-bound": "^1.0.2",
5556
- "es-errors": "^1.3.0",
5557
- "get-intrinsic": "^1.2.5",
5558
- "object-inspect": "^1.13.3",
5559
- "side-channel-map": "^1.0.1"
5560
- },
5561
- "engines": {
5562
- "node": ">= 0.4"
5563
- },
5564
- "funding": {
5565
- "url": "https://github.com/sponsors/ljharb"
5566
- }
5567
- },
5568
  "node_modules/source-map": {
5569
  "version": "0.5.7",
5570
  "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -5593,30 +4755,6 @@
5593
  "url": "https://github.com/sponsors/wooorm"
5594
  }
5595
  },
5596
- "node_modules/statuses": {
5597
- "version": "2.0.2",
5598
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
5599
- "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
5600
- "license": "MIT",
5601
- "engines": {
5602
- "node": ">= 0.8"
5603
- }
5604
- },
5605
- "node_modules/string-width": {
5606
- "version": "4.2.3",
5607
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
5608
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
5609
- "license": "MIT",
5610
- "optional": true,
5611
- "dependencies": {
5612
- "emoji-regex": "^8.0.0",
5613
- "is-fullwidth-code-point": "^3.0.0",
5614
- "strip-ansi": "^6.0.1"
5615
- },
5616
- "engines": {
5617
- "node": ">=8"
5618
- }
5619
- },
5620
  "node_modules/stringify-entities": {
5621
  "version": "4.0.4",
5622
  "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -5630,19 +4768,6 @@
5630
  "url": "https://github.com/sponsors/wooorm"
5631
  }
5632
  },
5633
- "node_modules/strip-ansi": {
5634
- "version": "6.0.1",
5635
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
5636
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
5637
- "license": "MIT",
5638
- "optional": true,
5639
- "dependencies": {
5640
- "ansi-regex": "^5.0.1"
5641
- },
5642
- "engines": {
5643
- "node": ">=8"
5644
- }
5645
- },
5646
  "node_modules/strip-json-comments": {
5647
  "version": "3.1.1",
5648
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -5720,15 +4845,6 @@
5720
  "url": "https://github.com/sponsors/SuperchupuDev"
5721
  }
5722
  },
5723
- "node_modules/toidentifier": {
5724
- "version": "1.0.1",
5725
- "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
5726
- "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
5727
- "license": "MIT",
5728
- "engines": {
5729
- "node": ">=0.6"
5730
- }
5731
- },
5732
  "node_modules/trim-lines": {
5733
  "version": "3.0.1",
5734
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -5766,19 +4882,6 @@
5766
  "node": ">= 0.8.0"
5767
  }
5768
  },
5769
- "node_modules/type-is": {
5770
- "version": "1.6.18",
5771
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
5772
- "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
5773
- "license": "MIT",
5774
- "dependencies": {
5775
- "media-typer": "0.3.0",
5776
- "mime-types": "~2.1.24"
5777
- },
5778
- "engines": {
5779
- "node": ">= 0.6"
5780
- }
5781
- },
5782
  "node_modules/unified": {
5783
  "version": "11.0.5",
5784
  "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -5873,15 +4976,6 @@
5873
  "url": "https://opencollective.com/unified"
5874
  }
5875
  },
5876
- "node_modules/unpipe": {
5877
- "version": "1.0.0",
5878
- "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
5879
- "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
5880
- "license": "MIT",
5881
- "engines": {
5882
- "node": ">= 0.8"
5883
- }
5884
- },
5885
  "node_modules/update-browserslist-db": {
5886
  "version": "1.2.2",
5887
  "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
@@ -5923,24 +5017,6 @@
5923
  "punycode": "^2.1.0"
5924
  }
5925
  },
5926
- "node_modules/utils-merge": {
5927
- "version": "1.0.1",
5928
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
5929
- "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
5930
- "license": "MIT",
5931
- "engines": {
5932
- "node": ">= 0.4.0"
5933
- }
5934
- },
5935
- "node_modules/vary": {
5936
- "version": "1.1.2",
5937
- "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
5938
- "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
5939
- "license": "MIT",
5940
- "engines": {
5941
- "node": ">= 0.8"
5942
- }
5943
- },
5944
  "node_modules/vfile": {
5945
  "version": "6.0.3",
5946
  "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
 
10
  "dependencies": {
11
  "@emotion/react": "^11.14.0",
12
  "@emotion/styled": "^11.14.1",
 
13
  "@mui/icons-material": "^7.3.6",
14
  "@mui/material": "^7.3.6",
15
  "@react-spring/web": "^10.0.3",
 
16
  "framer-motion": "^12.23.26",
17
  "highlight.js": "^11.11.1",
18
  "react": "^19.2.0",
 
1070
  "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1071
  }
1072
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1073
  "node_modules/@humanfs/core": {
1074
  "version": "0.19.1",
1075
  "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
 
1978
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1979
  }
1980
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1981
  "node_modules/acorn": {
1982
  "version": "8.15.0",
1983
  "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
 
2019
  "url": "https://github.com/sponsors/epoberezkin"
2020
  }
2021
  },
 
 
 
 
 
 
 
 
 
 
2022
  "node_modules/ansi-styles": {
2023
  "version": "4.3.0",
2024
  "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
 
2042
  "dev": true,
2043
  "license": "Python-2.0"
2044
  },
 
 
 
 
 
 
2045
  "node_modules/babel-plugin-macros": {
2046
  "version": "3.1.0",
2047
  "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
 
2083
  "baseline-browser-mapping": "dist/cli.js"
2084
  }
2085
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2086
  "node_modules/brace-expansion": {
2087
  "version": "1.1.12",
2088
  "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
 
2129
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
2130
  }
2131
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2132
  "node_modules/callsites": {
2133
  "version": "3.1.0",
2134
  "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
 
2221
  "url": "https://github.com/sponsors/wooorm"
2222
  }
2223
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
2224
  "node_modules/clsx": {
2225
  "version": "2.1.1",
2226
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
 
2266
  "dev": true,
2267
  "license": "MIT"
2268
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2269
  "node_modules/convert-source-map": {
2270
  "version": "1.9.0",
2271
  "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
 
2285
  "url": "https://opencollective.com/express"
2286
  }
2287
  },
 
 
 
 
 
 
2288
  "node_modules/cosmiconfig": {
2289
  "version": "7.1.0",
2290
  "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
 
2367
  "dev": true,
2368
  "license": "MIT"
2369
  },
 
 
 
 
 
 
 
 
 
2370
  "node_modules/dequal": {
2371
  "version": "2.0.3",
2372
  "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
 
2375
  "node": ">=6"
2376
  }
2377
  },
 
 
 
 
 
 
 
 
 
 
2378
  "node_modules/devlop": {
2379
  "version": "1.1.0",
2380
  "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
 
2397
  "csstype": "^3.0.2"
2398
  }
2399
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2400
  "node_modules/electron-to-chromium": {
2401
  "version": "1.5.267",
2402
  "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
 
2404
  "dev": true,
2405
  "license": "ISC"
2406
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2407
  "node_modules/error-ex": {
2408
  "version": "1.3.4",
2409
  "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
 
2413
  "is-arrayish": "^0.2.1"
2414
  }
2415
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2416
  "node_modules/esbuild": {
2417
  "version": "0.25.12",
2418
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
 
2465
  "node": ">=6"
2466
  }
2467
  },
 
 
 
 
 
 
2468
  "node_modules/escape-string-regexp": {
2469
  "version": "4.0.0",
2470
  "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
 
2671
  "node": ">=0.10.0"
2672
  }
2673
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2674
  "node_modules/extend": {
2675
  "version": "3.0.2",
2676
  "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
 
2728
  "node": ">=16.0.0"
2729
  }
2730
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2731
  "node_modules/find-root": {
2732
  "version": "1.1.0",
2733
  "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
 
2772
  "dev": true,
2773
  "license": "ISC"
2774
  },
 
 
 
 
 
 
 
 
 
2775
  "node_modules/framer-motion": {
2776
  "version": "12.23.26",
2777
  "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
 
2799
  }
2800
  }
2801
  },
 
 
 
 
 
 
 
 
 
2802
  "node_modules/fsevents": {
2803
  "version": "2.3.3",
2804
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
 
2832
  "node": ">=6.9.0"
2833
  }
2834
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2835
  "node_modules/glob-parent": {
2836
  "version": "6.0.2",
2837
  "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
 
2858
  "url": "https://github.com/sponsors/sindresorhus"
2859
  }
2860
  },
 
 
 
 
 
 
 
 
 
 
 
 
2861
  "node_modules/has-flag": {
2862
  "version": "4.0.0",
2863
  "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
 
2868
  "node": ">=8"
2869
  }
2870
  },
 
 
 
 
 
 
 
 
 
 
 
 
2871
  "node_modules/hasown": {
2872
  "version": "2.0.2",
2873
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
 
2989
  "url": "https://opencollective.com/unified"
2990
  }
2991
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2992
  "node_modules/ignore": {
2993
  "version": "5.3.2",
2994
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
 
3025
  "node": ">=0.8.19"
3026
  }
3027
  },
 
 
 
 
 
 
3028
  "node_modules/inline-style-parser": {
3029
  "version": "0.2.7",
3030
  "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
3031
  "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
3032
  },
 
 
 
 
 
 
 
 
 
3033
  "node_modules/is-alphabetical": {
3034
  "version": "2.0.1",
3035
  "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
 
3092
  "node": ">=0.10.0"
3093
  }
3094
  },
 
 
 
 
 
 
 
 
 
 
3095
  "node_modules/is-glob": {
3096
  "version": "4.0.3",
3097
  "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
 
3310
  "url": "https://github.com/sponsors/wooorm"
3311
  }
3312
  },
 
 
 
 
 
 
 
 
 
3313
  "node_modules/mdast-util-find-and-replace": {
3314
  "version": "3.0.2",
3315
  "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
 
3576
  "url": "https://opencollective.com/unified"
3577
  }
3578
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3579
  "node_modules/micromark": {
3580
  "version": "4.0.2",
3581
  "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
 
4111
  }
4112
  ]
4113
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4114
  "node_modules/minimatch": {
4115
  "version": "3.1.2",
4116
  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
 
4171
  "dev": true,
4172
  "license": "MIT"
4173
  },
 
 
 
 
 
 
 
 
 
4174
  "node_modules/node-releases": {
4175
  "version": "2.0.27",
4176
  "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
 
4187
  "node": ">=0.10.0"
4188
  }
4189
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4190
  "node_modules/optionator": {
4191
  "version": "0.9.4",
4192
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
 
4290
  "url": "https://github.com/sponsors/sindresorhus"
4291
  }
4292
  },
 
 
 
 
 
 
 
 
 
4293
  "node_modules/path-exists": {
4294
  "version": "4.0.0",
4295
  "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
 
4316
  "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
4317
  "license": "MIT"
4318
  },
 
 
 
 
 
 
4319
  "node_modules/path-type": {
4320
  "version": "4.0.0",
4321
  "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
 
4404
  "url": "https://github.com/sponsors/wooorm"
4405
  }
4406
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
4407
  "node_modules/punycode": {
4408
  "version": "2.3.1",
4409
  "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
 
4414
  "node": ">=6"
4415
  }
4416
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4417
  "node_modules/react": {
4418
  "version": "19.2.3",
4419
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
 
4682
  "fsevents": "~2.3.2"
4683
  }
4684
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4685
  "node_modules/scheduler": {
4686
  "version": "0.27.0",
4687
  "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
 
4698
  "semver": "bin/semver.js"
4699
  }
4700
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4701
  "node_modules/set-cookie-parser": {
4702
  "version": "2.7.2",
4703
  "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
4704
  "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
4705
  "license": "MIT"
4706
  },
 
 
 
 
 
 
4707
  "node_modules/shebang-command": {
4708
  "version": "2.0.0",
4709
  "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
 
4727
  "node": ">=8"
4728
  }
4729
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4730
  "node_modules/source-map": {
4731
  "version": "0.5.7",
4732
  "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
 
4755
  "url": "https://github.com/sponsors/wooorm"
4756
  }
4757
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4758
  "node_modules/stringify-entities": {
4759
  "version": "4.0.4",
4760
  "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
 
4768
  "url": "https://github.com/sponsors/wooorm"
4769
  }
4770
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
4771
  "node_modules/strip-json-comments": {
4772
  "version": "3.1.1",
4773
  "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
 
4845
  "url": "https://github.com/sponsors/SuperchupuDev"
4846
  }
4847
  },
 
 
 
 
 
 
 
 
 
4848
  "node_modules/trim-lines": {
4849
  "version": "3.0.1",
4850
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
 
4882
  "node": ">= 0.8.0"
4883
  }
4884
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
4885
  "node_modules/unified": {
4886
  "version": "11.0.5",
4887
  "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
 
4976
  "url": "https://opencollective.com/unified"
4977
  }
4978
  },
 
 
 
 
 
 
 
 
 
4979
  "node_modules/update-browserslist-db": {
4980
  "version": "1.2.2",
4981
  "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
 
5017
  "punycode": "^2.1.0"
5018
  }
5019
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5020
  "node_modules/vfile": {
5021
  "version": "6.0.3",
5022
  "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
package.json CHANGED
@@ -12,15 +12,13 @@
12
  "start:prod": "NODE_ENV=production node server/index.js"
13
  },
14
  "dependencies": {
 
15
  "@emotion/react": "^11.14.0",
16
  "@emotion/styled": "^11.14.1",
17
- "@huggingface/hub": "^2.8.1",
18
  "@mui/icons-material": "^7.3.6",
19
  "@mui/material": "^7.3.6",
20
  "@react-spring/web": "^10.0.3",
21
- "express": "^4.21.2",
22
  "framer-motion": "^12.23.26",
23
- "fuse.js": "^7.1.0",
24
  "highlight.js": "^11.11.1",
25
  "react": "^19.2.0",
26
  "react-dom": "^19.2.0",
 
12
  "start:prod": "NODE_ENV=production node server/index.js"
13
  },
14
  "dependencies": {
15
+ "express": "^4.21.2",
16
  "@emotion/react": "^11.14.0",
17
  "@emotion/styled": "^11.14.1",
 
18
  "@mui/icons-material": "^7.3.6",
19
  "@mui/material": "^7.3.6",
20
  "@react-spring/web": "^10.0.3",
 
21
  "framer-motion": "^12.23.26",
 
22
  "highlight.js": "^11.11.1",
23
  "react": "^19.2.0",
24
  "react-dom": "^19.2.0",
scripts/evaluate-prompt-v2.py DELETED
@@ -1,445 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Prompt-v2 evaluation harness.
4
-
5
- Re-runs the LLM categorization on every JS app currently served by
6
- /api/js-apps with a tightened prompt, and prints a side-by-side
7
- diff against the live (v1) classifications.
8
-
9
- This file lives outside the server runtime - it never gets pushed
10
- to the Space. It's only meant to be hand-iterated until the diff
11
- looks right, then the chosen prompt is ported into server/categorize.js
12
- and server/categories.js.
13
-
14
- Run:
15
- python3 scripts/evaluate-prompt-v2.py
16
- """
17
- from __future__ import annotations
18
-
19
- import json
20
- import os
21
- import re
22
- import ssl
23
- import sys
24
- import time
25
- import urllib.error
26
- import urllib.request
27
- from pathlib import Path
28
- from typing import Any
29
-
30
- # Python 3.14 on macOS ships without the system CA bundle wired into
31
- # urllib by default - HF endpoints fail with CERTIFICATE_VERIFY_FAILED.
32
- # This script is dev-local only and only talks to huggingface.co, so
33
- # bypassing verification here is acceptable (would NEVER do this in
34
- # the server runtime).
35
- _SSL_CTX = ssl._create_unverified_context() # noqa: S323
36
-
37
- HF_INFERENCE_URL = "https://router.huggingface.co/v1/chat/completions"
38
- MODEL = "meta-llama/Llama-3.1-8B-Instruct"
39
- TEMPERATURE = 0
40
- MAX_TOKENS = 120
41
-
42
- README_MAX_CHARS = 3000
43
- MAX_CATEGORIES_PER_APP = 3
44
-
45
- JS_APPS_URL = "https://pollen-robotics-reachy-mini.hf.space/api/js-apps"
46
-
47
-
48
- # ──────────────────────────────────────────────────────────────────────
49
- # Taxonomy v2 - 9 slugs (added "games")
50
- # ──────────────────────────────────────────────────────────────────────
51
-
52
- CATEGORIES_V2: list[tuple[str, str]] = [
53
- (
54
- "music",
55
- "Music creation, playback, beats, songs, DJ mixing, instruments, "
56
- "blind-test music games. Requires actual music (rhythm/melody/song). "
57
- "NOT arbitrary audio (Morse code, alarms, TTS, sound effects).",
58
- ),
59
- (
60
- "dance",
61
- "Dance choreographies, motion replay, kinetic shows, "
62
- "recording/replaying robot movements, dance parties.",
63
- ),
64
- (
65
- "voice",
66
- "Reachy talks, listens, or holds a real-time voice conversation: "
67
- "TTS players, LLM-driven chat (OpenAI Realtime, Claude, Perplexity), "
68
- "wake-word demos, daily reports/news/weather read aloud.",
69
- ),
70
- (
71
- "storytelling",
72
- "Narrative stories WITH plot and characters: interactive fiction, "
73
- "bedtime tales, audio adventures, choose-your-own-adventure. "
74
- "NOT for daily reports, news, weather, or Q&A (use `voice`).",
75
- ),
76
- (
77
- "kids",
78
- "Apps that EXPLICITLY target children: the words kids / children / "
79
- "'for curious minds' / bedtime / 'learning for kids' must appear in "
80
- "the name or description, OR the app must be obviously kid-targeted. "
81
- "Combines with `storytelling`, `voice`, or `games`. Lifestyle, "
82
- "sports, weather, general conversation are NOT kids.",
83
- ),
84
- (
85
- "games",
86
- "Apps with a play loop: scores, rounds, win/lose conditions, "
87
- "quizzes, puzzles, sports simulations, dice/oracles (magic 8-ball), "
88
- "arcade-style mini-games.",
89
- ),
90
- (
91
- "vision",
92
- "Apps where Reachy's camera DRIVES behaviour: face/hand/pose "
93
- "tracking, image classification, gesture detection, visual mimicry. "
94
- "NOT for apps that merely stream or display the camera feed.",
95
- ),
96
- (
97
- "companion",
98
- "Apps with an EXPLICIT emotional/personality/buddy framing in the "
99
- "name or description (words like companion, buddy, mood, emotional, "
100
- "personality, pet, Tamagotchi). Being friendly is not enough.",
101
- ),
102
- (
103
- "dev-tools",
104
- "RESERVED slug — see DECISION ALGORITHM step 1 below. Use ONLY "
105
- "for pure technical artefacts (debug utilities, SDK probes, "
106
- "minimal protocol demos, dev-only test spaces) with no end-user "
107
- "experience. When used, it is the SOLE category — never combined.",
108
- ),
109
- ]
110
-
111
- ALLOWED = {slug for slug, _ in CATEGORIES_V2}
112
-
113
-
114
- # ──────────────────────────────────────────────────────────────────────
115
- # Few-shot examples - cover the main pitfalls of v1
116
- # ──────────────────────────────────────────────────────────────────────
117
-
118
- FEW_SHOT = [
119
- (
120
- "Reachy Morse",
121
- "Send Morse code through Reachy's speaker.",
122
- ["dev-tools"],
123
- "(STEP 1 veto: pure technical artefact. NOT music.)",
124
- ),
125
- (
126
- "WebRTC Demo",
127
- "Minimal WebRTC connection between Reachy and the browser.",
128
- ["dev-tools"],
129
- "(STEP 1 veto: protocol demo. NOT vision.)",
130
- ),
131
- (
132
- "TTS Reachy Mini",
133
- "Browser TTS that plays out of Reachy Mini's speaker.",
134
- ["voice"],
135
- "(USER-FACING speech output is voice, NOT dev-tools.)",
136
- ),
137
- (
138
- "Reachy Mochi - Emotional Companion",
139
- "Your pocket buddy that develops a mood and personality over time.",
140
- ["companion"],
141
- "(explicit emotional/companion framing)",
142
- ),
143
- (
144
- "Reachy Alive",
145
- "(README empty; name suggests autonomy and life-like presence)",
146
- ["companion"],
147
- "(USE THE NAME when the README is empty; 'alive' = companion-like)",
148
- ),
149
- (
150
- "Daily Surf Report",
151
- "Reachy reads today's surf report out loud.",
152
- ["voice"],
153
- "(NOT storytelling — a report has no narrative arc. "
154
- "NOT kids — surfing/sports are not kid-targeted.)",
155
- ),
156
- (
157
- "Music Quiz",
158
- "Play a blind test music game with a dancing Reachy.",
159
- ["music", "games", "dance"],
160
- "(multi-label: three slugs truly co-apply, ordered by relevance)",
161
- ),
162
- (
163
- "Mime Bot",
164
- "Reachy mimics your face live from your webcam.",
165
- ["vision"],
166
- "(NOT companion — mimicry is visual, no emotional framing.)",
167
- ),
168
- ]
169
-
170
-
171
- def build_system_prompt() -> str:
172
- taxonomy = "\n".join(f"- {slug}: {desc}" for slug, desc in CATEGORIES_V2)
173
- examples = "\n".join(
174
- f" - {name!r}: {desc!r}\n"
175
- f" → {{\"categories\": {json.dumps(cats)}}} {hint}"
176
- for name, desc, cats, hint in FEW_SHOT
177
- )
178
- return f"""You classify a Reachy Mini robot app into a CLOSED list of categories.
179
-
180
- OUTPUT FORMAT
181
- Return ONLY a single JSON object: {{"categories": ["slug1", "slug2"]}}.
182
- Pick 1 to {MAX_CATEGORIES_PER_APP} slugs, ordered from most to least relevant.
183
- Use the EXACT slug. No prose, no code fences, no commentary outside the JSON.
184
-
185
- DECISION ALGORITHM (apply in order)
186
-
187
- STEP 1 — `dev-tools` veto
188
- Is this app a PURE technical artefact with no user-facing experience
189
- beyond "here is how the SDK / API works"?
190
- Examples that pass the veto: WebRTC demo, SDK probe, debug utility,
191
- raw remote-control interface, dev-only test space.
192
- Examples that DO NOT pass the veto (they are user-facing apps):
193
- TTS players, voice chat, music apps, storytelling, companions —
194
- even when the README is dev-heavy.
195
- ─ YES → return {{"categories": ["dev-tools"]}} and STOP. Never combine.
196
- ─ NO → continue to STEP 2.
197
-
198
- STEP 2 — Pick 1 to {MAX_CATEGORIES_PER_APP} user-facing slugs from the
199
- list below. Choose the MOST SPECIFIC categories. Order from most to
200
- least relevant. Multi-label is encouraged when two categories truly
201
- co-apply (e.g. music-and-dance, kids storytelling, vision game).
202
- If the README is empty or very sparse, USE THE NAME AND DESCRIPTION
203
- as the primary signal — do not bail to an empty list just because the
204
- README is thin.
205
-
206
- STEP 3 — Strict slug rules (each must hold, or DO NOT use the slug)
207
- - `companion`: requires EXPLICIT emotional / personality / buddy framing
208
- (companion, buddy, friend, mood, emotional, personality, pet,
209
- Tamagotchi-like, "alive", "life companion"). Being friendly is not
210
- enough.
211
- - `music`: requires actual music — rhythm, melody, songs, beats, DJ
212
- sets, instruments, music quizzes. Arbitrary audio (Morse, alarms,
213
- TTS, sound effects) is NOT music.
214
- - `vision`: requires the camera to DRIVE behaviour (tracking,
215
- classification, mimicry). Merely streaming or displaying the camera
216
- (WebRTC demos, remote-control viewers) is NOT vision.
217
- - `storytelling`: requires a narrative ARC — plot, characters, scenes.
218
- Daily reports, news, weather, Q&A are NOT storytelling (they are
219
- `voice`).
220
- - `games`: requires a play loop — score, rounds, win/lose, puzzles,
221
- quizzes, dice/oracles, sports simulations.
222
- - `kids`: requires kid-targeted framing (kids/children/curious minds/
223
- bedtime/learning for kids) in the name or description. Lifestyle,
224
- sports, weather, general conversation are NOT kids.
225
-
226
- AVAILABLE CATEGORIES
227
- {taxonomy}
228
-
229
- REFERENCE EXAMPLES
230
- {examples}
231
-
232
- Do not include any text outside the JSON object."""
233
-
234
-
235
- def build_user_prompt(name: str, description: str, readme: str) -> str:
236
- return (
237
- f"App name: {name or '(unknown)'}\n"
238
- f"Short description: {description or '(none)'}\n\n"
239
- f"README excerpt:\n{readme or '(no README available)'}\n\n"
240
- f"Return the JSON now."
241
- )
242
-
243
-
244
- # ──────────────────────────────────────────────────────────────────────
245
- # README fetch + clean (mirrors server/categorize.js)
246
- # ─────────────────────────────────────────────────────────────��────────
247
-
248
- def fetch_readme(space_id: str) -> str:
249
- url = f"https://huggingface.co/spaces/{space_id}/raw/main/README.md"
250
- try:
251
- with urllib.request.urlopen(url, timeout=10, context=_SSL_CTX) as r:
252
- return r.read().decode("utf-8", errors="replace")
253
- except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
254
- return ""
255
-
256
-
257
- def clean_readme(raw: str) -> str:
258
- if not raw:
259
- return ""
260
- txt = raw
261
- txt = re.sub(r"^---\n[\s\S]*?\n---\n?", "", txt)
262
- txt = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", txt)
263
- txt = re.sub(r"<img\b[^>]*>", "", txt, flags=re.IGNORECASE)
264
- txt = re.sub(r"\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)", "", txt)
265
- txt = re.sub(r"</?[a-zA-Z][^>]*>", "", txt)
266
- txt = re.sub(r"\n{3,}", "\n\n", txt)
267
- if len(txt) > README_MAX_CHARS:
268
- cut = txt.rfind("\n\n", 0, README_MAX_CHARS)
269
- if cut > README_MAX_CHARS // 2:
270
- txt = txt[:cut]
271
- else:
272
- txt = txt[:README_MAX_CHARS]
273
- return txt.strip()
274
-
275
-
276
- # ──────────────────────────────────────────────────────────────────────
277
- # LLM call
278
- # ──────────────────────────────────────────────────────────────────────
279
-
280
- def call_llm(hf_token: str, system: str, user: str) -> str | None:
281
- body = json.dumps(
282
- {
283
- "model": MODEL,
284
- "messages": [
285
- {"role": "system", "content": system},
286
- {"role": "user", "content": user},
287
- ],
288
- "temperature": TEMPERATURE,
289
- "max_tokens": MAX_TOKENS,
290
- "response_format": {"type": "json_object"},
291
- }
292
- ).encode("utf-8")
293
- req = urllib.request.Request(
294
- HF_INFERENCE_URL,
295
- data=body,
296
- headers={
297
- "Authorization": f"Bearer {hf_token}",
298
- "Content-Type": "application/json",
299
- # Cloudflare in front of the router 403s the default
300
- # "Python-urllib/x.y" UA. Any reasonable UA passes.
301
- "User-Agent": "reachy-mini-prompt-eval/1.0",
302
- },
303
- method="POST",
304
- )
305
- try:
306
- with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as r:
307
- data = json.loads(r.read().decode("utf-8"))
308
- return data.get("choices", [{}])[0].get("message", {}).get("content")
309
- except urllib.error.HTTPError as e:
310
- detail = e.read().decode("utf-8", errors="replace")[:200]
311
- print(f" ✗ LLM HTTP {e.code}: {detail}", file=sys.stderr)
312
- return None
313
- except Exception as e: # noqa: BLE001
314
- print(f" ✗ LLM error: {e}", file=sys.stderr)
315
- return None
316
-
317
-
318
- def extract_json_obj(text: str) -> dict[str, Any] | None:
319
- if not text:
320
- return None
321
- start = text.find("{")
322
- if start == -1:
323
- return None
324
- depth = 0
325
- for i in range(start, len(text)):
326
- c = text[i]
327
- if c == "{":
328
- depth += 1
329
- elif c == "}":
330
- depth -= 1
331
- if depth == 0:
332
- try:
333
- return json.loads(text[start : i + 1])
334
- except json.JSONDecodeError:
335
- return None
336
- return None
337
-
338
-
339
- def sanitize(raw: Any) -> list[str]:
340
- if not isinstance(raw, list):
341
- return []
342
- out: list[str] = []
343
- seen: set[str] = set()
344
- for v in raw:
345
- if not isinstance(v, str):
346
- continue
347
- slug = v.strip().lower()
348
- if not slug or slug in seen or slug not in ALLOWED:
349
- continue
350
- seen.add(slug)
351
- out.append(slug)
352
- if len(out) >= MAX_CATEGORIES_PER_APP:
353
- break
354
- return out
355
-
356
-
357
- # ──────────────────────────────────────────────────────────────────────
358
- # Main
359
- # ──────────────────────────────────────────────────────────────────────
360
-
361
- def read_hf_token() -> str:
362
- if os.environ.get("HF_TOKEN"):
363
- return os.environ["HF_TOKEN"]
364
- env_file = Path(__file__).resolve().parent.parent / ".env"
365
- if env_file.exists():
366
- for line in env_file.read_text().splitlines():
367
- m = re.match(r"^\s*HF_TOKEN\s*=\s*(.*?)\s*$", line)
368
- if m:
369
- v = m.group(1).strip().strip('"').strip("'")
370
- if v:
371
- return v
372
- raise SystemExit("HF_TOKEN not found in env or .env")
373
-
374
-
375
- def fetch_live_classifications() -> list[dict[str, Any]]:
376
- with urllib.request.urlopen(JS_APPS_URL, timeout=30, context=_SSL_CTX) as r:
377
- return json.load(r)["apps"]
378
-
379
-
380
- def main() -> int:
381
- hf_token = read_hf_token()
382
- apps = fetch_live_classifications()
383
- print(f"Loaded {len(apps)} JS apps from prod.\n")
384
-
385
- system = build_system_prompt()
386
- print(f"System prompt: {len(system)} chars, {system.count(chr(10))} lines.\n")
387
-
388
- results: list[dict[str, Any]] = []
389
-
390
- for i, app in enumerate(apps, 1):
391
- sid = app["id"]
392
- name = app.get("name") or sid.split("/")[-1]
393
- desc = (
394
- app.get("description")
395
- or (app.get("extra") or {}).get("cardData", {}).get("short_description")
396
- or ""
397
- )
398
- old_cats = app.get("categories") or []
399
-
400
- raw_readme = fetch_readme(sid)
401
- readme = clean_readme(raw_readme)
402
- user = build_user_prompt(name, desc, readme)
403
-
404
- reply = call_llm(hf_token, system, user)
405
- new_cats = sanitize((extract_json_obj(reply) or {}).get("categories"))
406
-
407
- changed = set(old_cats) != set(new_cats)
408
- marker = "Δ" if changed else " "
409
- print(
410
- f" {marker} ({i:>2}/{len(apps)}) {name[:36]:<37} "
411
- f"old=[{', '.join(old_cats)}]"
412
- + (f" → new=[{', '.join(new_cats)}]" if changed else "")
413
- )
414
-
415
- results.append(
416
- {
417
- "id": sid,
418
- "name": name,
419
- "old": old_cats,
420
- "new": new_cats,
421
- "changed": changed,
422
- }
423
- )
424
- time.sleep(0.25)
425
-
426
- print()
427
- print("─" * 80)
428
- print("DIFF (only changed entries)")
429
- print("─" * 80)
430
- for r in results:
431
- if not r["changed"]:
432
- continue
433
- print(
434
- f" {r['name'][:38]:<40} "
435
- f"[{', '.join(r['old']) or '∅'}] → [{', '.join(r['new']) or '∅'}]"
436
- )
437
-
438
- changed_count = sum(1 for r in results if r["changed"])
439
- print()
440
- print(f"{changed_count}/{len(results)} entries changed.")
441
- return 0
442
-
443
-
444
- if __name__ == "__main__":
445
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categories.js DELETED
@@ -1,189 +0,0 @@
1
- /**
2
- * Predefined taxonomy for JS Reachy Mini apps.
3
- *
4
- * These slugs are the ONLY valid output values for the LLM
5
- * inference step (anything else is dropped at parse time) and
6
- * the values consumers (mobile shell, website) filter on.
7
- *
8
- * Why a closed list instead of free-form tags
9
- * ──────────────────────────────────────────
10
- * The HF Spaces catalog has no usable categorization for the
11
- * reachy_mini_js_app subset (only platform/SDK tags). We bridge
12
- * the gap by inferring categories with an LLM, but we have to
13
- * constrain the model's output: a closed list keeps category
14
- * pages stable, lets us pre-pick emojis/labels, and avoids the
15
- * "30 near-duplicate slugs" problem you'd get with free-form.
16
- *
17
- * Bumping the taxonomy
18
- * ────────────────────
19
- * Adding, removing or renaming a slug changes the meaning of
20
- * cached entries. Bump TAXONOMY_VERSION when you do that: the
21
- * cache layer compares each entry's `taxonomyVersion` against
22
- * the live one and recomputes stale ones on the next pass.
23
- */
24
-
25
- /**
26
- * Bump this when the slug list OR the descriptions change in a way
27
- * that affects the LLM output. The cache layer invalidates entries
28
- * whose taxonomyVersion is older than this and reclassifies them on
29
- * the next pass. We don't bump it for cosmetic edits (label / emoji)
30
- * since those don't reach the LLM.
31
- *
32
- * History:
33
- * - v1: initial 8-slug taxonomy.
34
- * - v2: added `games`, tightened `kids` + `dev-tools` descriptions,
35
- * switched the prompt to a DECISION ALGORITHM with few-shot.
36
- * - v3: switched from multi-label (up to 3 slugs) to single-label
37
- * (exactly 1 slug). Each app surfaces in exactly one category
38
- * section on the mobile shell - no duplicates across swipers.
39
- * - v4: renamed `dance` to `motion` (broader: marionette, replay,
40
- * choreography without music). Music-driven dance parties
41
- * now belong to `music` since music is what drives them.
42
- */
43
- export const TAXONOMY_VERSION = 4;
44
-
45
- /**
46
- * Canonical category list. Keep slugs short, kebab-case, and
47
- * memorable: they end up in URLs (e.g. `?cat=music`) and in
48
- * filter chips on mobile.
49
- *
50
- * The `description` field is the SOLE source of truth the LLM
51
- * sees - keep them factual, scope-bounded, and example-led so
52
- * the model has signal for both inclusion and exclusion.
53
- */
54
- export const CATEGORIES = [
55
- {
56
- slug: 'music',
57
- label: 'Music & Beats',
58
- emoji: '🎵',
59
- description:
60
- 'Music creation, playback, beats, songs, DJ mixing, instruments, ' +
61
- 'blind-test music games, AND music-driven dance parties (Reachy ' +
62
- 'dances to a song). Requires actual music (rhythm / melody / song). ' +
63
- 'Arbitrary audio (Morse code, alarms, TTS, sound effects) is NOT ' +
64
- 'music. Pure choreography without music belongs to `motion`.',
65
- },
66
- {
67
- slug: 'motion',
68
- label: 'Motion & Movement',
69
- emoji: '🦾',
70
- description:
71
- "Apps that drive Reachy's physical movement on its own: motion " +
72
- 'replay, marionette-style remote control of the body, kinetic ' +
73
- 'shows, choreographies WITHOUT music, expressive body language. ' +
74
- 'If the movement is synced to music, use `music` instead.',
75
- },
76
- {
77
- slug: 'voice',
78
- label: 'Voice & Conversation',
79
- emoji: '🗣️',
80
- description:
81
- 'Reachy talks, listens, or holds a real-time voice ' +
82
- 'conversation: TTS players, LLM-driven chat (OpenAI Realtime, ' +
83
- 'Claude, Perplexity), wake-word demos, daily reports / news / ' +
84
- 'weather read aloud.',
85
- },
86
- {
87
- slug: 'storytelling',
88
- label: 'Stories',
89
- emoji: '📖',
90
- description:
91
- 'Narrative stories WITH plot and characters: interactive ' +
92
- 'fiction, bedtime tales, audio adventures, choose-your-own-' +
93
- 'adventure. NOT for daily reports, news, weather, or Q&A ' +
94
- '(those are `voice`).',
95
- },
96
- {
97
- slug: 'kids',
98
- label: 'For Kids',
99
- emoji: '🧒',
100
- description:
101
- 'Apps that EXPLICITLY target children: the words kids / ' +
102
- "children / 'for curious minds' / bedtime / 'learning for kids' " +
103
- 'must appear in the name or description, OR the app must be ' +
104
- 'obviously kid-targeted. Combines with `storytelling`, `voice`, ' +
105
- 'or `games`. Lifestyle, sports, weather, generic personality / ' +
106
- 'narration / fun framings are NOT kids.',
107
- },
108
- {
109
- slug: 'games',
110
- label: 'Games & Play',
111
- emoji: '🎮',
112
- description:
113
- 'Apps with a play loop: scores, rounds, win/lose conditions, ' +
114
- 'quizzes, puzzles, sports simulations, dice/oracles (magic ' +
115
- '8-ball), arcade-style mini-games.',
116
- },
117
- {
118
- slug: 'vision',
119
- label: 'Vision & Camera',
120
- emoji: '👁️',
121
- description:
122
- "Apps where Reachy's camera DRIVES behaviour: face/hand/pose " +
123
- 'tracking, image classification, gesture detection, visual ' +
124
- 'mimicry. Merely streaming or displaying the camera feed ' +
125
- '(WebRTC demos, remote-control viewers) is NOT vision.',
126
- },
127
- {
128
- slug: 'companion',
129
- label: 'Companion',
130
- emoji: '🤝',
131
- description:
132
- 'Apps with an EXPLICIT emotional / personality / buddy framing ' +
133
- 'in the name or description (companion, buddy, friend, mood, ' +
134
- 'emotional, personality, pet, Tamagotchi-like, "alive", ' +
135
- '"life companion"). Being friendly is not enough.',
136
- },
137
- {
138
- slug: 'dev-tools',
139
- label: 'Dev & Demos',
140
- emoji: '🛠️',
141
- description:
142
- 'RESERVED slug - see DECISION ALGORITHM step 1 in the prompt. ' +
143
- 'Use ONLY for pure technical artefacts (debug utilities, SDK ' +
144
- 'probes, minimal protocol demos, dev-only test spaces) with no ' +
145
- 'end-user experience. When used, it is the SOLE category - ' +
146
- 'never combined with another slug.',
147
- },
148
- ];
149
-
150
- export const ALLOWED_SLUGS = new Set(CATEGORIES.map((c) => c.slug));
151
-
152
- export function isValidSlug(slug) {
153
- return ALLOWED_SLUGS.has(slug);
154
- }
155
-
156
- /**
157
- * Render the taxonomy as a bulleted list for the LLM prompt.
158
- * Format mirrors what the model is asked to output (slug first)
159
- * to nudge it towards copying the exact string back.
160
- */
161
- export function buildLlmCategoryList() {
162
- return CATEGORIES.map((c) => `- ${c.slug}: ${c.description}`).join('\n');
163
- }
164
-
165
- /**
166
- * Sanitize a raw LLM-returned list of slugs:
167
- * - drop non-strings
168
- * - lowercase + trim
169
- * - drop unknown slugs (hallucinations)
170
- * - dedupe while preserving order (the model orders by relevance)
171
- * - cap to MAX_CATEGORIES
172
- *
173
- * Returns a fresh array; never mutates input.
174
- */
175
- export function sanitizeSlugs(raw, maxCategories = 3) {
176
- if (!Array.isArray(raw)) return [];
177
- const seen = new Set();
178
- const out = [];
179
- for (const v of raw) {
180
- if (typeof v !== 'string') continue;
181
- const slug = v.trim().toLowerCase();
182
- if (!slug || seen.has(slug)) continue;
183
- if (!ALLOWED_SLUGS.has(slug)) continue;
184
- seen.add(slug);
185
- out.push(slug);
186
- if (out.length >= maxCategories) break;
187
- }
188
- return out;
189
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categorize.js DELETED
@@ -1,426 +0,0 @@
1
- /**
2
- * LLM-based category inference for JS Reachy Mini apps.
3
- *
4
- * Pipeline (`categorizeApp`)
5
- * ──────────────────────────
6
- * 1. Fetch the Space's README from HF Hub (raw)
7
- * 2. Strip frontmatter, images, badges, raw HTML, then truncate
8
- * 3. Call a chat LLM via HF Inference Providers (OpenAI-compatible)
9
- * with the predefined taxonomy + the app's name/description
10
- * 4. Parse JSON, validate against ALLOWED_SLUGS, keep up to 3
11
- *
12
- * Robustness contract
13
- * ───────────────────
14
- * `categorizeApp` NEVER throws on transient failure (network,
15
- * 429, malformed JSON). It returns `null`, which the cache layer
16
- * interprets as "not yet categorized; retry on the next pass".
17
- * Hard errors (HF_TOKEN missing) are signalled by a thrown
18
- * `HfTokenMissingError` so the caller can short-circuit the
19
- * whole batch.
20
- */
21
-
22
- import {
23
- buildLlmCategoryList,
24
- sanitizeSlugs,
25
- } from './categories.js';
26
-
27
- // HF Inference Providers - OpenAI-compatible router. Auto-routes
28
- // the request to whichever provider currently serves the model
29
- // (Together, Nebius, Fireworks, Sambanova...). The token must
30
- // have `Inference Providers` access (default for all PRO and
31
- // most FREE tokens since 2025).
32
- const HF_INFERENCE_URL = 'https://router.huggingface.co/v1/chat/completions';
33
-
34
- // 8B model: cheap, fast (~1 s per call), more than enough for a
35
- // closed-list multi-label classification with good descriptions.
36
- // If quality drifts we can swap to 70B without touching anything
37
- // else - the prompt is generic.
38
- const DEFAULT_MODEL = 'meta-llama/Llama-3.1-8B-Instruct';
39
-
40
- // README budget
41
- const README_MAX_CHARS = 3000;
42
-
43
- // Single-label classification: each app gets EXACTLY ONE slug -
44
- // the dominant one. The shape stays `string[]` for forward
45
- // compatibility (if we ever revert to multi-label, no API break),
46
- // but the array always contains 0 or 1 entry. Mobile chips and
47
- // "swipers per category" thus surface each app once and only once.
48
- const MAX_CATEGORIES_PER_APP = 1;
49
-
50
- // LLM call budget
51
- const LLM_TIMEOUT_MS = 30_000;
52
- const LLM_MAX_TOKENS = 120;
53
- const LLM_TEMPERATURE = 0;
54
-
55
- export class HfTokenMissingError extends Error {
56
- constructor() {
57
- super('HF_TOKEN env var is not set; cannot call HF Inference Providers.');
58
- this.name = 'HfTokenMissingError';
59
- }
60
- }
61
-
62
- /**
63
- * Fetch a Space's README from HF Hub. Returns the raw markdown
64
- * string, or `null` if the request fails (404, network, etc.) -
65
- * the caller falls back to "name + description only" in that case,
66
- * which is still enough signal for the LLM on most apps.
67
- */
68
- export async function fetchSpaceReadme(spaceId, { signal } = {}) {
69
- if (!spaceId || typeof spaceId !== 'string') return null;
70
- // The README of a HF Space lives at /spaces/<id>/raw/main/README.md.
71
- // The `raw` endpoint returns the file as-is (no Hub UI wrapping)
72
- // and is anonymous-friendly, so no auth is needed here.
73
- const url = `https://huggingface.co/spaces/${spaceId}/raw/main/README.md`;
74
- try {
75
- const res = await fetch(url, { signal });
76
- if (!res.ok) return null;
77
- return await res.text();
78
- } catch {
79
- return null;
80
- }
81
- }
82
-
83
- /**
84
- * Lightly clean a raw README so the LLM doesn't burn tokens on
85
- * boilerplate (HF frontmatter, badges, images) and so the actual
86
- * prose surfaces above the truncation budget.
87
- *
88
- * We keep transformations conservative: we never edit the
89
- * surrounding prose, we just delete decorative tokens. Anything
90
- * cosmetic-only that clearly isn't signal for classification
91
- * (badges, images, raw HTML).
92
- */
93
- export function cleanReadme(raw) {
94
- if (!raw || typeof raw !== 'string') return '';
95
- let txt = raw;
96
-
97
- // 1. Strip the YAML frontmatter at the very top (HF Spaces
98
- // ship a mandatory `---\n...metadata...\n---` block whose
99
- // fields are already exposed to us via the catalog payload,
100
- // so feeding them to the LLM is pure noise).
101
- txt = txt.replace(/^---\n[\s\S]*?\n---\n?/, '');
102
-
103
- // 2. Drop image markdown (`![alt](url)`) and HTML <img> tags.
104
- // Vision apps tend to load up READMEs with screenshots and
105
- // GIFs; the alt text is sometimes useful but more often it's
106
- // "demo.gif" - low signal/noise ratio.
107
- txt = txt.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
108
- txt = txt.replace(/<img\b[^>]*>/gi, '');
109
-
110
- // 3. Strip shields.io / GitHub badges (markdown links that
111
- // wrap an image). They survive (2) only when nested.
112
- txt = txt.replace(/\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)/g, '');
113
-
114
- // 4. Generic HTML stripping. Most READMEs are pure markdown,
115
- // but some authors embed `<details>`, `<sub>`, `<center>`
116
- // blocks. Keep the inner text, drop the tags.
117
- txt = txt.replace(/<\/?[a-zA-Z][^>]*>/g, '');
118
-
119
- // 5. Collapse runs of blank lines so trimming doesn't waste
120
- // tokens on the gap.
121
- txt = txt.replace(/\n{3,}/g, '\n\n');
122
-
123
- // 6. Truncate. We slice at the paragraph boundary closest to
124
- // the budget so we don't end mid-sentence.
125
- if (txt.length > README_MAX_CHARS) {
126
- const cut = txt.lastIndexOf('\n\n', README_MAX_CHARS);
127
- txt = txt.slice(0, cut > README_MAX_CHARS / 2 ? cut : README_MAX_CHARS);
128
- }
129
-
130
- return txt.trim();
131
- }
132
-
133
- /**
134
- * Few-shot examples woven into the system prompt.
135
- *
136
- * Each entry encodes a pitfall the v1 prompt fell into during the
137
- * 24-app eval (see `scripts/evaluate-prompt-v2.py`). Keep this list
138
- * tight - past ~10 examples the model starts pattern-matching
139
- * literally on the example names rather than applying the rules.
140
- *
141
- * Format: [name, description, expected_slugs, brief_justification]
142
- */
143
- const FEW_SHOT_EXAMPLES = [
144
- [
145
- 'Reachy Morse',
146
- "Send Morse code through Reachy's speaker.",
147
- ['dev-tools'],
148
- '(STEP 1 veto: pure technical artefact. NOT music.)',
149
- ],
150
- [
151
- 'WebRTC Demo',
152
- 'Minimal WebRTC connection between Reachy and the browser.',
153
- ['dev-tools'],
154
- '(STEP 1 veto: protocol demo. NOT vision.)',
155
- ],
156
- [
157
- 'TTS Reachy Mini',
158
- "Browser TTS that plays out of Reachy Mini's speaker.",
159
- ['voice'],
160
- '(USER-FACING speech output is voice, NOT dev-tools.)',
161
- ],
162
- [
163
- 'Reachy Mochi - Emotional Companion',
164
- 'Your pocket buddy that develops a mood and personality over time.',
165
- ['companion'],
166
- '(explicit emotional/companion framing)',
167
- ],
168
- [
169
- 'Reachy Alive',
170
- '(README empty; name suggests autonomy and life-like presence)',
171
- ['companion'],
172
- "(USE THE NAME when the README is empty; 'alive' = companion-like)",
173
- ],
174
- [
175
- 'Daily Surf Report',
176
- "Reachy reads today's surf report out loud.",
177
- ['voice'],
178
- '(NOT storytelling - a report has no narrative arc. ' +
179
- 'NOT kids - surfing/sports are not kid-targeted.)',
180
- ],
181
- [
182
- 'Music Quiz',
183
- 'Play a blind test music game with a dancing Reachy.',
184
- ['music'],
185
- '(single dominant slug - music wins over games because the app ' +
186
- "is primarily a music blind-test; the dancing is a side effect " +
187
- 'of the music and is captured by `music` too)',
188
- ],
189
- [
190
- 'Mime Bot',
191
- 'Reachy mimics your face live from your webcam.',
192
- ['vision'],
193
- '(NOT companion - mimicry is visual, no emotional framing.)',
194
- ],
195
- ];
196
-
197
- function renderFewShot() {
198
- return FEW_SHOT_EXAMPLES.map(([name, desc, slugs, hint]) => {
199
- const slugsJson = JSON.stringify(slugs);
200
- return (
201
- ` - ${JSON.stringify(name)}: ${JSON.stringify(desc)}\n` +
202
- ` → {"categories": ${slugsJson}} ${hint}`
203
- );
204
- }).join('\n');
205
- }
206
-
207
- /**
208
- * Build the chat messages handed to the LLM.
209
- *
210
- * The system prompt is structured as a 3-step DECISION ALGORITHM
211
- * rather than a flat list of rules, because the 8B-class model we
212
- * use (Llama-3.1-8B-Instruct) follows imperative procedures more
213
- * reliably than soft constraints. The `dev-tools` veto in STEP 1
214
- * is what stops the model from silently combining it with other
215
- * slugs on user-facing apps.
216
- *
217
- * The few-shot examples below the rules cover the v1 pitfalls
218
- * (companion hallucinations, music-on-audio, kids-on-personas,
219
- * storytelling-on-reports). Six is the sweet spot - more starts
220
- * over-fitting on example wording.
221
- */
222
- function buildMessages({ name, description, readme }) {
223
- const taxonomy = buildLlmCategoryList();
224
- const examples = renderFewShot();
225
- const system = `You classify a Reachy Mini robot app into a CLOSED list of categories.
226
-
227
- OUTPUT FORMAT
228
- Return ONLY a single JSON object: {"categories": ["slug"]}.
229
- Pick EXACTLY ONE slug - the single dominant category that best
230
- captures the app's primary identity. Use the EXACT slug. The list
231
- always contains 0 or 1 entry.
232
- No prose, no code fences, no commentary outside the JSON.
233
-
234
- DECISION ALGORITHM (apply in order)
235
-
236
- STEP 1 - \`dev-tools\` veto
237
- Is this app a PURE technical artefact with no user-facing experience
238
- beyond "here is how the SDK / API works"?
239
- Examples that pass the veto: WebRTC demo, SDK probe, debug utility,
240
- raw remote-control interface, dev-only test space.
241
- Examples that DO NOT pass the veto (they are user-facing apps):
242
- TTS players, voice chat, music apps, storytelling, companions -
243
- even when the README is dev-heavy.
244
- - YES -> return {"categories": ["dev-tools"]} and STOP.
245
- - NO -> continue to STEP 2.
246
-
247
- STEP 2 - Pick the SINGLE most dominant user-facing slug from the list
248
- below. Choose the slug that captures the app's primary identity, not
249
- every aspect it touches. When two slugs feel equally fitting, pick the
250
- one that a user would name FIRST when describing the app in one word.
251
- Examples of tie-breaks:
252
- - music-driven dance party (Reachy dances to a song) -> \`music\`.
253
- The music is what drives the experience.
254
- - pure choreography / marionette / motion replay without music ->
255
- \`motion\`. The movement is the experience.
256
- - storytelling + kids app -> prefer \`kids\` if it explicitly targets
257
- children, \`storytelling\` otherwise.
258
- - vision + games app -> prefer \`games\` if there is a play loop,
259
- \`vision\` if it is mostly a perception demo.
260
- If the README is empty or very sparse, USE THE NAME AND DESCRIPTION
261
- as the primary signal - do not bail to an empty list just because the
262
- README is thin.
263
-
264
- STEP 3 - Strict slug rules (each must hold, or DO NOT use the slug)
265
- - \`companion\`: requires EXPLICIT emotional / personality / buddy
266
- framing (companion, buddy, friend, mood, emotional, personality,
267
- pet, Tamagotchi-like, "alive", "life companion"). Being friendly is
268
- not enough.
269
- - \`music\`: requires actual music - rhythm, melody, songs, beats, DJ
270
- sets, instruments, music quizzes. Arbitrary audio (Morse, alarms,
271
- TTS, sound effects) is NOT music.
272
- - \`vision\`: requires the camera to DRIVE behaviour (tracking,
273
- classification, mimicry). Merely streaming or displaying the camera
274
- (WebRTC demos, remote-control viewers) is NOT vision.
275
- - \`storytelling\`: requires a narrative ARC - plot, characters, scenes.
276
- Daily reports, news, weather, Q&A are NOT storytelling (they are
277
- \`voice\`).
278
- - \`games\`: requires a play loop - score, rounds, win/lose, puzzles,
279
- quizzes, dice/oracles, sports simulations.
280
- - \`kids\`: requires kid-targeted framing (kids/children/curious minds/
281
- bedtime/learning for kids) in the name or description. Lifestyle,
282
- sports, weather, general conversation are NOT kids.
283
-
284
- AVAILABLE CATEGORIES
285
- ${taxonomy}
286
-
287
- REFERENCE EXAMPLES
288
- ${examples}
289
-
290
- Do not include any text outside the JSON object.`;
291
-
292
- const user =
293
- `App name: ${name || '(unknown)'}\n` +
294
- `Short description: ${description || '(none)'}\n\n` +
295
- `README excerpt:\n${readme || '(no README available)'}\n\n` +
296
- 'Return the JSON now.';
297
-
298
- return [
299
- { role: 'system', content: system },
300
- { role: 'user', content: user },
301
- ];
302
- }
303
-
304
- /**
305
- * Best-effort JSON extraction. Some 8B models still wrap the
306
- * answer in ``` fences or prepend "Sure, here you go:". We grab
307
- * the first balanced `{...}` block and parse that.
308
- */
309
- function extractJsonObject(text) {
310
- if (!text || typeof text !== 'string') return null;
311
- const start = text.indexOf('{');
312
- if (start === -1) return null;
313
- let depth = 0;
314
- for (let i = start; i < text.length; i++) {
315
- const ch = text[i];
316
- if (ch === '{') depth++;
317
- else if (ch === '}') {
318
- depth--;
319
- if (depth === 0) {
320
- const slice = text.slice(start, i + 1);
321
- try {
322
- return JSON.parse(slice);
323
- } catch {
324
- return null;
325
- }
326
- }
327
- }
328
- }
329
- return null;
330
- }
331
-
332
- /**
333
- * Call the HF Inference Providers chat endpoint. Returns the
334
- * raw assistant message string, or `null` on any error.
335
- */
336
- async function callLlm({ messages, model, signal }) {
337
- const token = process.env.HF_TOKEN;
338
- if (!token) throw new HfTokenMissingError();
339
-
340
- const body = {
341
- model,
342
- messages,
343
- temperature: LLM_TEMPERATURE,
344
- max_tokens: LLM_MAX_TOKENS,
345
- // `response_format` is honoured by some providers (Nebius,
346
- // Together) but ignored by others. It's a free upgrade when
347
- // present, harmless otherwise; the JSON-extractor below is
348
- // the real safety net.
349
- response_format: { type: 'json_object' },
350
- };
351
-
352
- let res;
353
- try {
354
- res = await fetch(HF_INFERENCE_URL, {
355
- method: 'POST',
356
- headers: {
357
- 'Authorization': `Bearer ${token}`,
358
- 'Content-Type': 'application/json',
359
- },
360
- body: JSON.stringify(body),
361
- signal,
362
- });
363
- } catch (err) {
364
- console.warn(`[categorize] LLM fetch failed: ${err.message}`);
365
- return null;
366
- }
367
-
368
- if (!res.ok) {
369
- const detail = await res.text().catch(() => '');
370
- console.warn(
371
- `[categorize] LLM HTTP ${res.status}: ${detail.slice(0, 200)}`,
372
- );
373
- return null;
374
- }
375
-
376
- let json;
377
- try {
378
- json = await res.json();
379
- } catch {
380
- return null;
381
- }
382
- return json?.choices?.[0]?.message?.content ?? null;
383
- }
384
-
385
- /**
386
- * Public entry point.
387
- *
388
- * Returns a string[] of validated slugs (0-3 items), or `null`
389
- * on transient failure so the caller can mark the entry "needs
390
- * retry" without writing a misleading empty list.
391
- *
392
- * Treat an empty array `[]` as "the LLM looked and concluded
393
- * none fit" - that's a valid, cacheable outcome.
394
- */
395
- export async function categorizeApp({
396
- name,
397
- description,
398
- spaceId,
399
- model = DEFAULT_MODEL,
400
- } = {}) {
401
- if (!spaceId) return null;
402
-
403
- const ctrl = new AbortController();
404
- const timeoutId = setTimeout(() => ctrl.abort(), LLM_TIMEOUT_MS);
405
-
406
- try {
407
- const rawReadme = await fetchSpaceReadme(spaceId, { signal: ctrl.signal });
408
- const readme = cleanReadme(rawReadme);
409
-
410
- const messages = buildMessages({ name, description, readme });
411
- const reply = await callLlm({ messages, model, signal: ctrl.signal });
412
- if (reply == null) return null;
413
-
414
- const obj = extractJsonObject(reply);
415
- if (!obj || !Array.isArray(obj.categories)) {
416
- console.warn(
417
- `[categorize] ${spaceId}: malformed LLM reply (truncated): ` +
418
- `${reply.slice(0, 120)}`,
419
- );
420
- return null;
421
- }
422
- return sanitizeSlugs(obj.categories, MAX_CATEGORIES_PER_APP);
423
- } finally {
424
- clearTimeout(timeoutId);
425
- }
426
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categoryCache.js DELETED
@@ -1,290 +0,0 @@
1
- /**
2
- * Persistent cache for inferred app categories, backed by a
3
- * HuggingFace dataset.
4
- *
5
- * Why a dataset (not a local file)
6
- * ────────────────────────────────
7
- * The website runs in a Docker HF Space. The container's
8
- * filesystem is wiped on every rebuild (and rebuilds happen
9
- * on every push, every model update, every Space restart).
10
- * Re-running 200 LLM calls every cold start would be wasteful
11
- * and slow the user-visible /api/js-apps for the first 30 s.
12
- *
13
- * Pushing the cache to a dataset gives us:
14
- * 1. Persistence across rebuilds and machine moves
15
- * 2. A versioned audit log of how categories evolve
16
- * 3. A single source of truth other tooling can consume
17
- * (the mobile shell could even read the dataset directly
18
- * if it ever wanted to bypass the website).
19
- *
20
- * Storage shape
21
- * ─────────────
22
- * <dataset>/categories.json
23
- *
24
- * {
25
- * "version": 1,
26
- * "taxonomyVersion": 1,
27
- * "updatedAt": "2026-05-10T11:08:42Z",
28
- * "entries": {
29
- * "<spaceId>": {
30
- * "lastModified": "2026-05-08T22:13:01Z",
31
- * "categories": ["storytelling", "kids", "voice"],
32
- * "categorizedAt": "2026-05-10T11:08:42Z",
33
- * "taxonomyVersion": 1
34
- * }
35
- * }
36
- * }
37
- *
38
- * In-memory tier
39
- * ──────────────
40
- * The Map<spaceId, entry> is the hot path. The dataset is
41
- * loaded once at boot and only flushed when entries actually
42
- * change (the warmup batch buffers writes and flushes once
43
- * at the end). All synchronous access goes through the Map.
44
- */
45
-
46
- import { commit, createRepo } from '@huggingface/hub';
47
-
48
- import { TAXONOMY_VERSION } from './categories.js';
49
-
50
- // Default location: a per-user dataset that the HF_TOKEN owner
51
- // definitely has write access to. Override with the env var
52
- // when promoting to the org-owned `pollen-robotics/...` dataset.
53
- const DEFAULT_DATASET = 'tfrere/reachy-mini-app-categories';
54
-
55
- const CACHE_FILE_PATH = 'categories.json';
56
- const CACHE_FORMAT_VERSION = 1;
57
-
58
- class CategoryCache {
59
- constructor() {
60
- this.entries = new Map();
61
- this.repoName = process.env.HF_CATEGORIES_DATASET || DEFAULT_DATASET;
62
- this.loaded = false;
63
- this.dirty = false;
64
- // Concurrency guard for `flush()` - we never want two
65
- // commit() calls fighting for the same parent commit.
66
- this.flushing = false;
67
- }
68
-
69
- /**
70
- * Load the dataset cache into memory. Best-effort: a missing
71
- * dataset, a 404, or a malformed JSON all collapse to "start
72
- * fresh, the warmup will repopulate". We never let cache load
73
- * failure block the server boot.
74
- */
75
- async load() {
76
- if (this.loaded) return;
77
- this.loaded = true;
78
-
79
- const url = `https://huggingface.co/datasets/${this.repoName}/resolve/main/${CACHE_FILE_PATH}`;
80
- try {
81
- const res = await fetch(url, {
82
- // Send the token even on a public dataset: it lets HF
83
- // bump our rate limit and keeps the path identical for
84
- // a future private dataset migration.
85
- headers: process.env.HF_TOKEN
86
- ? { Authorization: `Bearer ${process.env.HF_TOKEN}` }
87
- : undefined,
88
- });
89
- if (!res.ok) {
90
- if (res.status === 404) {
91
- console.log(
92
- `[CategoryCache] Dataset ${this.repoName} or ${CACHE_FILE_PATH} ` +
93
- `not found yet - starting empty.`,
94
- );
95
- } else {
96
- console.warn(
97
- `[CategoryCache] HTTP ${res.status} loading cache from ` +
98
- `${this.repoName}, starting empty.`,
99
- );
100
- }
101
- return;
102
- }
103
- const data = await res.json();
104
- const entries = data?.entries || {};
105
- let kept = 0;
106
- let staleTaxonomy = 0;
107
- for (const [id, raw] of Object.entries(entries)) {
108
- if (!raw || typeof raw !== 'object') continue;
109
- // Drop entries from a previous taxonomy: their slugs
110
- // may no longer exist or may have shifted meaning.
111
- // The warmup will re-run them.
112
- if (raw.taxonomyVersion !== TAXONOMY_VERSION) {
113
- staleTaxonomy++;
114
- continue;
115
- }
116
- this.entries.set(id, {
117
- lastModified: raw.lastModified || null,
118
- categories: Array.isArray(raw.categories) ? raw.categories : [],
119
- categorizedAt: raw.categorizedAt || null,
120
- taxonomyVersion: raw.taxonomyVersion,
121
- });
122
- kept++;
123
- }
124
- console.log(
125
- `[CategoryCache] Loaded ${kept} entries from ${this.repoName}` +
126
- (staleTaxonomy ? ` (dropped ${staleTaxonomy} stale taxonomy)` : ''),
127
- );
128
- } catch (err) {
129
- console.warn(
130
- `[CategoryCache] Load failed (${err.message}); starting empty.`,
131
- );
132
- }
133
- }
134
-
135
- get(spaceId) {
136
- return this.entries.get(spaceId) || null;
137
- }
138
-
139
- /**
140
- * Decide whether `spaceId` needs a fresh classification call.
141
- * It does when:
142
- * - we have no entry at all, OR
143
- * - the Space's `lastModified` has moved past our cached one
144
- * (the README may have changed - re-classify), OR
145
- * - the taxonomy version moved (handled at load() time, but
146
- * belt-and-braces for hot reloads).
147
- */
148
- needsCategorization(spaceId, lastModified) {
149
- const entry = this.entries.get(spaceId);
150
- if (!entry) return true;
151
- if (entry.taxonomyVersion !== TAXONOMY_VERSION) return true;
152
- if (lastModified && entry.lastModified !== lastModified) return true;
153
- return false;
154
- }
155
-
156
- set(spaceId, { categories, lastModified }) {
157
- if (!Array.isArray(categories)) return;
158
- const next = {
159
- lastModified: lastModified || null,
160
- categories: [...categories],
161
- categorizedAt: new Date().toISOString(),
162
- taxonomyVersion: TAXONOMY_VERSION,
163
- };
164
- const prev = this.entries.get(spaceId);
165
- // Skip the dirty flag if nothing actually changed - avoids
166
- // a useless commit when a refresh confirms the same labels.
167
- if (
168
- prev &&
169
- prev.lastModified === next.lastModified &&
170
- prev.taxonomyVersion === next.taxonomyVersion &&
171
- JSON.stringify(prev.categories) === JSON.stringify(next.categories)
172
- ) {
173
- return;
174
- }
175
- this.entries.set(spaceId, next);
176
- this.dirty = true;
177
- }
178
-
179
- /**
180
- * Persist the in-memory cache to the dataset (one commit, one
181
- * file). No-op if nothing has changed since the last flush.
182
- *
183
- * Auto-creates the dataset on first write if it doesn't exist
184
- * yet (so a brand-new `HF_CATEGORIES_DATASET` value bootstraps
185
- * cleanly without manual setup).
186
- */
187
- async flush() {
188
- if (!this.dirty || this.flushing) return;
189
- if (!process.env.HF_TOKEN) {
190
- console.warn('[CategoryCache] HF_TOKEN missing; skipping flush.');
191
- return;
192
- }
193
- this.flushing = true;
194
- try {
195
- const payload = this.serialize();
196
- const blob = new Blob([JSON.stringify(payload, null, 2)], {
197
- type: 'application/json',
198
- });
199
-
200
- const repo = { type: 'dataset', name: this.repoName };
201
- const credentials = { accessToken: process.env.HF_TOKEN };
202
-
203
- // First attempt: plain commit. If the dataset doesn't
204
- // exist yet, the SDK throws and we fall through to
205
- // create-then-commit. We never assume the dataset exists
206
- // - that lets a fresh deploy auto-bootstrap.
207
- try {
208
- await commit({
209
- repo,
210
- credentials,
211
- title: `Update categories (${this.entries.size} apps)`,
212
- operations: [
213
- {
214
- operation: 'addOrUpdate',
215
- path: CACHE_FILE_PATH,
216
- content: blob,
217
- },
218
- ],
219
- });
220
- } catch (err) {
221
- const msg = err?.message || '';
222
- const looksMissing =
223
- msg.includes('404') ||
224
- msg.toLowerCase().includes('not found') ||
225
- msg.toLowerCase().includes('does not exist');
226
- if (!looksMissing) throw err;
227
- console.log(
228
- `[CategoryCache] Dataset ${this.repoName} missing - creating it.`,
229
- );
230
- await createRepo({
231
- repo,
232
- credentials,
233
- private: false,
234
- // Re-using the same blob so the initial commit ships
235
- // the cache content (instead of an empty repo
236
- // followed by a no-op commit).
237
- files: [
238
- {
239
- path: CACHE_FILE_PATH,
240
- content: await blob.arrayBuffer(),
241
- },
242
- ],
243
- });
244
- }
245
-
246
- this.dirty = false;
247
- console.log(
248
- `[CategoryCache] Flushed ${this.entries.size} entries to ${this.repoName}`,
249
- );
250
- } catch (err) {
251
- // We deliberately swallow flush errors so a HF outage
252
- // doesn't break the running server. The next set() will
253
- // re-flag dirty=true and the next flush() will retry.
254
- console.error(
255
- `[CategoryCache] Flush failed: ${err?.message || err}`,
256
- );
257
- } finally {
258
- this.flushing = false;
259
- }
260
- }
261
-
262
- serialize() {
263
- const entries = {};
264
- for (const [id, entry] of this.entries) {
265
- entries[id] = entry;
266
- }
267
- return {
268
- version: CACHE_FORMAT_VERSION,
269
- taxonomyVersion: TAXONOMY_VERSION,
270
- updatedAt: new Date().toISOString(),
271
- entries,
272
- };
273
- }
274
-
275
- /**
276
- * Diagnostic snapshot for /api/js-apps's `categorization`
277
- * sub-payload. Lets the mobile shell decide whether to show
278
- * "loading categories..." or to render the chips immediately.
279
- */
280
- stats() {
281
- return {
282
- total: this.entries.size,
283
- dataset: this.repoName,
284
- taxonomyVersion: TAXONOMY_VERSION,
285
- };
286
- }
287
- }
288
-
289
- // Singleton: there's only one cache per server process.
290
- export const categoryCache = new CategoryCache();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/index.js CHANGED
@@ -1,123 +1,19 @@
1
  import express from 'express';
2
- import { existsSync, readFileSync } from 'fs';
3
  import path from 'path';
4
  import { fileURLToPath } from 'url';
5
 
6
- import { categorizeApp, HfTokenMissingError } from './categorize.js';
7
- import { categoryCache } from './categoryCache.js';
8
-
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
 
11
- // Load `.env` from the repo root in dev. In production (HF Space)
12
- // the platform already injects the secrets as env vars, so this
13
- // loader silently no-ops. We avoid the `dotenv` dep on purpose -
14
- // the format is trivial, and reproducing it inline keeps the
15
- // runtime closure tiny.
16
- (function loadDotenv() {
17
- try {
18
- const envPath = path.join(__dirname, '..', '.env');
19
- if (!existsSync(envPath)) return;
20
- const text = readFileSync(envPath, 'utf8');
21
- for (const line of text.split(/\r?\n/)) {
22
- const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i);
23
- if (!m) continue;
24
- const [, key, raw] = m;
25
- let value = raw;
26
- if (
27
- (value.startsWith('"') && value.endsWith('"')) ||
28
- (value.startsWith("'") && value.endsWith("'"))
29
- ) {
30
- value = value.slice(1, -1);
31
- }
32
- // Existing env wins (so `HF_TOKEN=foo node …` overrides .env).
33
- if (process.env[key] === undefined) process.env[key] = value;
34
- }
35
- } catch {
36
- /* best-effort - missing or malformed .env never blocks boot */
37
- }
38
- })();
39
-
40
  const app = express();
41
  const PORT = process.env.PORT || 7860;
42
 
43
  // Cache configuration
44
- const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
45
  const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json';
46
  const HF_SPACES_API = 'https://huggingface.co/api/spaces';
47
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
48
  const HF_SPACES_LIMIT = 1000;
49
 
50
- // Tag that gates the JS-only subset surfaced by /api/js-apps and
51
- // fed to the LLM categorizer. Mirrors the filter the mobile shell
52
- // applies today client-side; the route lets us retire that filter
53
- // from the mobile codebase down the line.
54
- const JS_APP_TAG = 'reachy_mini_js_app';
55
-
56
- // =====================================================================
57
- // App icon convention
58
- // =====================================================================
59
- //
60
- // Convention: an app MAY commit `icon.svg` (preferred) or
61
- // `icon.png` at the root of its HF Space repository. When present,
62
- // the mobile shell + desktop store render it as the app glyph
63
- // instead of the front-matter `emoji:` codepoint.
64
- //
65
- // We resolve the icon ONCE at indexing time (here) rather than
66
- // probing per-client because:
67
- // 1. We already pull `siblings` from `?full=true` (one cheap
68
- // hub call returns the file list for every app), so the
69
- // lookup is a pure JS filter, no extra network.
70
- // 2. Clients see a single field (`iconUrl`) in the payload and
71
- // don't have to know about HF resolve URLs, LFS pointers,
72
- // or the candidate-order race ("SVG wins if both exist").
73
- // 3. The HF API caps probes at ~hub side; doing it server-side
74
- // keeps fanout under a 5-minute TTL behind ONE token, instead
75
- // of every mobile shell hammering `huggingface.co/resolve/`
76
- // to discover icons.
77
- //
78
- // Resolution order: `icon.svg` → `icon.png`. SVG first because the
79
- // same asset scales cleanly across every mount point (small rail
80
- // tile, larger pinned tile, iframe header) from a single file.
81
- // Extra formats can be added to `ICON_CANDIDATES` if needed; order
82
- // matters - the first match wins.
83
- const ICON_CANDIDATES = ['icon.svg', 'icon.png'];
84
-
85
- /**
86
- * Look for a standard app icon file at the root of the Space.
87
- * Returns the absolute HF resolve URL when found, `null` otherwise.
88
- *
89
- * We hit `resolve/main/` (not `raw/main/`) so:
90
- * - LFS pointers follow transparently (large PNGs work).
91
- * - `Content-Type` comes from the extension, which `<img>` needs.
92
- * - The URL is cacheable cross-session by the browser, so
93
- * repeated mounts of the same app glyph don't re-fetch.
94
- */
95
- function findIconUrl(spaceId, siblings) {
96
- if (!spaceId || !Array.isArray(siblings)) return null;
97
- // Build a Set of root-level filenames for O(1) candidate
98
- // lookups. HF returns `siblings` as `[{ rfilename: "path/in/repo" }, ...]`,
99
- // so we filter to repo-root (no slash) before testing.
100
- const rootFiles = new Set();
101
- for (const s of siblings) {
102
- const name = s && typeof s.rfilename === 'string' ? s.rfilename : null;
103
- if (!name) continue;
104
- if (name.includes('/')) continue;
105
- rootFiles.add(name);
106
- }
107
- for (const candidate of ICON_CANDIDATES) {
108
- if (rootFiles.has(candidate)) {
109
- return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`;
110
- }
111
- }
112
- return null;
113
- }
114
-
115
- // Serialised LLM batch concurrency: we want at most one
116
- // categorization sweep running at a time, regardless of how many
117
- // /api/js-apps requests come in. The flag also prevents the
118
- // startup warm-up and an on-demand refresh from racing each other.
119
- let categorizationBatchRunning = false;
120
-
121
  // In-memory cache
122
  let appsCache = {
123
  data: null,
@@ -126,7 +22,6 @@ let appsCache = {
126
  };
127
 
128
  // Fetch apps from HuggingFace API
129
- // Returns format compatible with desktop app (with url, source_kind, extra)
130
  async function fetchAppsFromHF() {
131
  console.log('[Cache] Fetching apps from HuggingFace API...');
132
 
@@ -148,94 +43,36 @@ async function fetchAppsFromHF() {
148
  const allSpaces = await spacesResponse.json();
149
  console.log(`[Cache] Fetched ${allSpaces.length} spaces from HuggingFace`);
150
 
151
- // 3. Build apps list in desktop-compatible format
152
  const allApps = allSpaces.map(space => {
153
  const spaceId = space.id || '';
154
  const tags = space.tags || [];
155
  const isOfficial = officialSet.has(spaceId.toLowerCase());
156
  const isPythonApp = tags.includes('reachy_mini_python_app');
157
- const author = spaceId.split('/')[0];
158
- const name = spaceId.split('/').pop();
159
 
160
- // Server-resolved icon URL. Looks for `icon.svg` or `icon.png`
161
- // at the repo root via the `siblings` list returned by
162
- // `?full=true`. See `findIconUrl()` above for the rationale.
163
- // `null` when the author hasn't shipped one; clients fall
164
- // back to the front-matter emoji.
165
- const iconUrl = findIconUrl(spaceId, space.siblings);
166
-
167
  return {
168
- // Core fields (used by both website and desktop)
169
  id: spaceId,
170
- name,
171
  description: space.cardData?.short_description || '',
172
- url: `https://huggingface.co/spaces/${spaceId}`,
173
- source_kind: 'hf_space',
 
 
174
  isOfficial,
175
- iconUrl,
176
-
177
- // Extra metadata (desktop-compatible structure)
178
- extra: {
179
- id: spaceId,
180
- author,
181
- likes: space.likes || 0,
182
- downloads: space.downloads || 0,
183
- createdAt: space.createdAt || null,
184
- lastModified: space.lastModified,
185
- runtime: space.runtime || null,
186
- tags,
187
- isPythonApp,
188
- cardData: {
189
- emoji: space.cardData?.emoji || (isPythonApp ? '📦' : '🌐'),
190
- short_description: space.cardData?.short_description || '',
191
- sdk: space.cardData?.sdk || null,
192
- tags: space.cardData?.tags || [],
193
- // Preserve other cardData fields
194
- ...space.cardData,
195
- },
196
- },
197
  };
198
  });
199
 
200
- // Deduplicate by name: forks keep the same repo name (e.g. 4 spaces
201
- // named "reachy_mini_conversation_app" from different authors).
202
- // Priority: 1) official, 2) oldest (original), 3) most likes as tiebreaker.
203
- const deduped = new Map();
204
- for (const app of allApps) {
205
- const key = app.name.toLowerCase();
206
- const existing = deduped.get(key);
207
- if (!existing) {
208
- deduped.set(key, app);
209
- continue;
210
- }
211
- // Official always wins
212
- if (app.isOfficial && !existing.isOfficial) {
213
- deduped.set(key, app);
214
- continue;
215
- }
216
- if (existing.isOfficial) continue;
217
- // Oldest wins (the original is created before its forks)
218
- const appDate = app.extra.createdAt ? new Date(app.extra.createdAt).getTime() : Infinity;
219
- const existingDate = existing.extra.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity;
220
- if (appDate < existingDate) {
221
- deduped.set(key, app);
222
- } else if (appDate === existingDate && (app.extra.likes || 0) > (existing.extra.likes || 0)) {
223
- deduped.set(key, app);
224
- }
225
- }
226
- const uniqueApps = [...deduped.values()];
227
-
228
- console.log(`[Cache] Deduplicated ${allApps.length} → ${uniqueApps.length} apps (removed ${allApps.length - uniqueApps.length} forks with duplicate names)`);
229
-
230
  // Sort: official first, then by likes
231
- uniqueApps.sort((a, b) => {
232
  if (a.isOfficial !== b.isOfficial) {
233
  return a.isOfficial ? -1 : 1;
234
  }
235
- return (b.extra.likes || 0) - (a.extra.likes || 0);
236
  });
237
 
238
- return uniqueApps;
239
  } catch (err) {
240
  console.error('[Cache] Error fetching apps:', err);
241
  throw err;
@@ -295,237 +132,6 @@ app.get('/api/apps', async (req, res) => {
295
  }
296
  });
297
 
298
- // =====================================================================
299
- // JS apps + LLM-inferred categories
300
- // =====================================================================
301
- //
302
- // `/api/js-apps` is a curated view on top of `/api/apps`:
303
- // 1. Filter on the `reachy_mini_js_app` tag (the mobile-embeddable subset).
304
- // 2. Enrich each entry with `categories` + `categories_source`,
305
- // sourced from a persistent dataset cache (see categoryCache.js).
306
- //
307
- // Categories are inferred lazily by an LLM from each Space's
308
- // README. The first request after a cold start may see entries
309
- // with `categories: null` while the warmup batch is still in
310
- // flight; subsequent requests pick them up as the cache fills.
311
-
312
- /**
313
- * Pull the JS-app subset out of the global apps cache and fold
314
- * in cached categories. Pure, synchronous-ish (the only async
315
- * call is to the upstream `getApps()` which has its own cache).
316
- */
317
- async function getJsApps() {
318
- const apps = await getApps();
319
- const jsApps = apps.filter((a) => {
320
- const tags = a?.extra?.tags;
321
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
322
- });
323
-
324
- return jsApps.map((app) => {
325
- const cached = categoryCache.get(app.id);
326
- return {
327
- ...app,
328
- categories: cached ? cached.categories : null,
329
- categories_source: cached ? 'inferred' : null,
330
- categorized_at: cached ? cached.categorizedAt : null,
331
- };
332
- });
333
- }
334
-
335
- /**
336
- * Run one classification pass over `jsApps`. Skips entries whose
337
- * cache is still fresh (same `lastModified`, same taxonomy).
338
- *
339
- * Serial on purpose: HF Inference Providers don't love bursts
340
- * from a single token, and total throughput on ~50 apps stays
341
- * well under a minute. We slip a small jitter between calls to
342
- * smooth the curve further.
343
- */
344
- async function runCategorizationBatch(jsApps) {
345
- if (categorizationBatchRunning) {
346
- console.log('[Categorize] Batch already running, skipping.');
347
- return;
348
- }
349
- if (!process.env.HF_TOKEN) {
350
- console.warn(
351
- '[Categorize] HF_TOKEN not set; skipping batch. Set it in .env ' +
352
- 'or the Space secrets to enable category inference.',
353
- );
354
- return;
355
- }
356
-
357
- const todo = jsApps.filter((app) =>
358
- categoryCache.needsCategorization(app.id, app?.extra?.lastModified),
359
- );
360
-
361
- if (todo.length === 0) {
362
- console.log(
363
- `[Categorize] All ${jsApps.length} JS apps are already categorized.`,
364
- );
365
- return;
366
- }
367
-
368
- categorizationBatchRunning = true;
369
- console.log(
370
- `[Categorize] Starting batch: ${todo.length}/${jsApps.length} app(s) need classification.`,
371
- );
372
-
373
- let success = 0;
374
- let failed = 0;
375
- let aborted = false;
376
-
377
- for (let i = 0; i < todo.length; i++) {
378
- const app = todo[i];
379
- const desc =
380
- app.description ||
381
- app.extra?.cardData?.short_description ||
382
- '';
383
- try {
384
- const slugs = await categorizeApp({
385
- spaceId: app.id,
386
- name: app.name,
387
- description: desc,
388
- });
389
- if (slugs == null) {
390
- failed++;
391
- console.log(
392
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: transient failure, will retry next pass`,
393
- );
394
- } else {
395
- categoryCache.set(app.id, {
396
- categories: slugs,
397
- lastModified: app.extra?.lastModified || null,
398
- });
399
- success++;
400
- console.log(
401
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: ${
402
- slugs.length ? slugs.join(', ') : '(no fit)'
403
- }`,
404
- );
405
- }
406
- } catch (err) {
407
- if (err instanceof HfTokenMissingError) {
408
- console.warn(
409
- '[Categorize] HF_TOKEN missing mid-batch; aborting cleanly.',
410
- );
411
- aborted = true;
412
- break;
413
- }
414
- failed++;
415
- console.warn(
416
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: error - ${err.message}`,
417
- );
418
- }
419
-
420
- // 250 ms cooldown between calls. Below this, the HF Provider
421
- // router occasionally rate-limits a hot token.
422
- await new Promise((resolve) => setTimeout(resolve, 250));
423
- }
424
-
425
- console.log(
426
- `[Categorize] Batch done: ${success} ok, ${failed} failed${aborted ? ' (aborted)' : ''}.`,
427
- );
428
- // Persist the new entries even if some failed - partial
429
- // progress is strictly better than none, and the failed
430
- // entries will be retried on the next pass.
431
- await categoryCache.flush();
432
-
433
- categorizationBatchRunning = false;
434
- }
435
-
436
- /**
437
- * Wrap the diagnostic snapshot for the API payload. Lets
438
- * consumers (mobile shell, website) decide whether to show
439
- * "loading categories..." or render chips immediately.
440
- */
441
- function buildCategorizationStats(jsApps) {
442
- let withCategories = 0;
443
- for (const app of jsApps) {
444
- if (app.categories && app.categories.length >= 0 && app.categories_source) {
445
- withCategories++;
446
- }
447
- }
448
- return {
449
- enabled: !!process.env.HF_TOKEN,
450
- total: jsApps.length,
451
- classified: withCategories,
452
- pending: jsApps.length - withCategories,
453
- inProgress: categorizationBatchRunning,
454
- ...categoryCache.stats(),
455
- };
456
- }
457
-
458
- app.get('/api/js-apps', async (req, res) => {
459
- try {
460
- const apps = await getJsApps();
461
-
462
- // Background top-up: if any entry is still uncategorized
463
- // (or a Space's lastModified moved since we last looked),
464
- // fire off a batch. We DO NOT await it - the response goes
465
- // out immediately with whatever the cache currently knows.
466
- const needsWork = apps.some(
467
- (a) =>
468
- !a.categories_source ||
469
- categoryCache.needsCategorization(a.id, a.extra?.lastModified),
470
- );
471
- if (needsWork) {
472
- // `void` to make it crystal clear we don't expect a value;
473
- // the batch logs its own progress.
474
- void runCategorizationBatch(apps).catch((err) => {
475
- console.error('[Categorize] Background batch crashed:', err);
476
- });
477
- }
478
-
479
- res.json({
480
- apps,
481
- cached: true,
482
- cacheAge: appsCache.lastFetch
483
- ? Math.round((Date.now() - appsCache.lastFetch) / 1000)
484
- : 0,
485
- count: apps.length,
486
- categorization: buildCategorizationStats(apps),
487
- });
488
- } catch (err) {
489
- console.error('[API] /api/js-apps error:', err);
490
- res.status(500).json({ error: 'Failed to fetch JS apps' });
491
- }
492
- });
493
-
494
- // Manual trigger for a categorization sweep, useful when
495
- // hand-tuning the taxonomy or testing the LLM prompt without
496
- // waiting for the next /api/js-apps hit.
497
- app.post('/api/js-apps/refresh-categories', async (req, res) => {
498
- try {
499
- const apps = await getJsApps();
500
- void runCategorizationBatch(apps).catch((err) => {
501
- console.error('[Categorize] Manual batch crashed:', err);
502
- });
503
- res.json({
504
- ok: true,
505
- message: `Categorization batch kicked off for ${apps.length} JS apps.`,
506
- stats: buildCategorizationStats(apps),
507
- });
508
- } catch (err) {
509
- res.status(500).json({ error: 'Failed to trigger refresh' });
510
- }
511
- });
512
-
513
- // OAuth config endpoint - expose public OAuth variables to the frontend
514
- // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces)
515
- app.get('/api/oauth-config', (req, res) => {
516
- const clientId = process.env.OAUTH_CLIENT_ID;
517
- const scopes = process.env.OAUTH_SCOPES || 'openid profile';
518
-
519
- if (!clientId) {
520
- return res.status(503).json({
521
- error: 'OAuth not configured',
522
- hint: 'Make sure hf_oauth: true is set in README.md and the Space has been rebuilt',
523
- });
524
- }
525
-
526
- res.json({ clientId, scopes });
527
- });
528
-
529
  // Health check
530
  app.get('/api/health', (req, res) => {
531
  res.json({
@@ -562,29 +168,8 @@ app.get('*', (req, res) => {
562
  async function warmCache() {
563
  console.log('[Startup] Pre-warming cache...');
564
  try {
565
- const apps = await getApps();
566
  console.log('[Startup] Cache warmed successfully');
567
-
568
- // Categorization warm-up: fire the JS-app batch in the
569
- // background so the first /api/js-apps caller doesn't
570
- // shoulder the cold-start cost. Order: load the dataset
571
- // cache first (cheap, one HTTP call), then run the batch
572
- // for stale entries only.
573
- void (async () => {
574
- try {
575
- await categoryCache.load();
576
- const jsApps = apps.filter((a) => {
577
- const tags = a?.extra?.tags;
578
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
579
- });
580
- console.log(
581
- `[Startup] Found ${jsApps.length} JS apps; checking categories...`,
582
- );
583
- await runCategorizationBatch(jsApps);
584
- } catch (err) {
585
- console.error('[Startup] Categorization warm-up failed:', err);
586
- }
587
- })();
588
  } catch (err) {
589
  console.error('[Startup] Failed to warm cache:', err);
590
  }
 
1
  import express from 'express';
 
2
  import path from 'path';
3
  import { fileURLToPath } from 'url';
4
 
 
 
 
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  const app = express();
8
  const PORT = process.env.PORT || 7860;
9
 
10
  // Cache configuration
11
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
12
  const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json';
13
  const HF_SPACES_API = 'https://huggingface.co/api/spaces';
14
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
15
  const HF_SPACES_LIMIT = 1000;
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  // In-memory cache
18
  let appsCache = {
19
  data: null,
 
22
  };
23
 
24
  // Fetch apps from HuggingFace API
 
25
  async function fetchAppsFromHF() {
26
  console.log('[Cache] Fetching apps from HuggingFace API...');
27
 
 
43
  const allSpaces = await spacesResponse.json();
44
  console.log(`[Cache] Fetched ${allSpaces.length} spaces from HuggingFace`);
45
 
46
+ // 3. Build apps list with isOfficial and isPythonApp flags
47
  const allApps = allSpaces.map(space => {
48
  const spaceId = space.id || '';
49
  const tags = space.tags || [];
50
  const isOfficial = officialSet.has(spaceId.toLowerCase());
51
  const isPythonApp = tags.includes('reachy_mini_python_app');
 
 
52
 
 
 
 
 
 
 
 
53
  return {
 
54
  id: spaceId,
55
+ name: spaceId.split('/').pop(),
56
  description: space.cardData?.short_description || '',
57
+ cardData: space.cardData || {},
58
+ likes: space.likes || 0,
59
+ lastModified: space.lastModified,
60
+ author: spaceId.split('/')[0],
61
  isOfficial,
62
+ isPythonApp,
63
+ tags,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  };
65
  });
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  // Sort: official first, then by likes
68
+ allApps.sort((a, b) => {
69
  if (a.isOfficial !== b.isOfficial) {
70
  return a.isOfficial ? -1 : 1;
71
  }
72
+ return (b.likes || 0) - (a.likes || 0);
73
  });
74
 
75
+ return allApps;
76
  } catch (err) {
77
  console.error('[Cache] Error fetching apps:', err);
78
  throw err;
 
132
  }
133
  });
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  // Health check
136
  app.get('/api/health', (req, res) => {
137
  res.json({
 
168
  async function warmCache() {
169
  console.log('[Startup] Pre-warming cache...');
170
  try {
171
+ await getApps();
172
  console.log('[Startup] Cache warmed successfully');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  } catch (err) {
174
  console.error('[Startup] Failed to warm cache:', err);
175
  }
src/App.jsx CHANGED
@@ -1,9 +1,8 @@
1
  import { useEffect } from 'react';
2
- import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
3
  import { ThemeProvider, CssBaseline } from '@mui/material';
4
  import theme from './theme/theme';
5
  import { AppsProvider } from './context/AppsContext';
6
- import { AuthProvider } from './context/AuthContext';
7
 
8
  import Home from './pages/Home';
9
  import Download from './pages/Download';
@@ -13,59 +12,14 @@ import Buy from './pages/Buy';
13
  import GettingStarted from './pages/GettingStarted';
14
  import Build from './pages/Build';
15
 
16
- /**
17
- * Handle hash-to-path redirect for HuggingFace Spaces iframe embedding.
18
- *
19
- * HF propagates the parent page's hash to the iframe on initial load.
20
- * For example, visiting huggingface.co/reachy-mini#/apps will load the
21
- * iframe at *.hf.space/#/apps. This component reads that hash and
22
- * converts it to a BrowserRouter path (e.g. /apps).
23
- */
24
- function HashRedirect() {
25
- const navigate = useNavigate();
26
-
27
- useEffect(() => {
28
- const hash = window.location.hash;
29
- // Match hash routes like #/apps, #/download, #apps, #download, etc.
30
- if (hash && hash.length > 1) {
31
- // Support both #/apps and #apps formats
32
- const path = hash.startsWith('#/') ? hash.slice(1) : `/${hash.slice(1)}`;
33
- // Use replaceState to cleanly remove hash without triggering navigation
34
- window.history.replaceState(null, '', window.location.pathname);
35
- navigate(path, { replace: true });
36
- }
37
- }, [navigate]);
38
-
39
- return null;
40
- }
41
-
42
- /**
43
- * Sync the current route back to the HF parent page via postMessage.
44
- * This updates the URL in the browser address bar so users can
45
- * copy/share deep links (e.g. huggingface.co/reachy-mini#/apps).
46
- *
47
- * Also handles scrollTo query parameter for anchor-like behavior.
48
- */
49
- function RouteSync() {
50
  const location = useLocation();
51
 
52
  useEffect(() => {
53
- // Sync current path to parent frame hash (for HF Spaces embedding)
54
- const isInIframe = window.parent !== window;
55
- if (isInIframe && location.pathname !== '/') {
56
- window.parent.postMessage(
57
- { hash: `#${location.pathname}` },
58
- 'https://huggingface.co'
59
- );
60
- } else if (isInIframe && location.pathname === '/') {
61
- // Clear hash when on home page
62
- window.parent.postMessage({ hash: '' }, 'https://huggingface.co');
63
- }
64
-
65
- // Handle scrollTo query parameter
66
  const params = new URLSearchParams(location.search);
67
  const scrollTo = params.get('scrollTo');
68
-
69
  if (scrollTo) {
70
  // Retry mechanism to wait for element to be rendered
71
  const scrollToElement = (retries = 0) => {
@@ -73,11 +27,14 @@ function RouteSync() {
73
  if (element) {
74
  element.scrollIntoView({ behavior: 'smooth', block: 'center' });
75
  } else if (retries < 10) {
 
76
  setTimeout(() => scrollToElement(retries + 1), 100);
77
  }
78
  };
 
79
  setTimeout(() => scrollToElement(), 300);
80
  } else {
 
81
  window.scrollTo({ top: 0, behavior: 'smooth' });
82
  }
83
  }, [location.pathname, location.search]);
@@ -89,25 +46,20 @@ export default function App() {
89
  return (
90
  <ThemeProvider theme={theme}>
91
  <CssBaseline />
92
- <AuthProvider>
93
- <AppsProvider>
94
- <BrowserRouter>
95
- <HashRedirect />
96
- <RouteSync />
97
- <Routes>
98
- <Route path="/" element={<Home />} />
99
- <Route path="/getting-started" element={<GettingStarted />} />
100
- <Route path="https://huggingface.co/docs/reachy_mini/" element={<Build />} />
101
- <Route path="/download" element={<Download />} />
102
- <Route path="https://huggingface.co/docs/reachy_mini/troubleshooting" element={<FAQ />} />
103
- <Route path="/apps" element={<Apps />} />
104
- <Route path="/buy" element={<Buy />} />
105
- {/* Catch-all: redirect unknown routes to home */}
106
- <Route path="*" element={<Navigate to="/" replace />} />
107
- </Routes>
108
- </BrowserRouter>
109
- </AppsProvider>
110
- </AuthProvider>
111
  </ThemeProvider>
112
  );
113
  }
 
1
  import { useEffect } from 'react';
2
+ import { HashRouter, Routes, Route, useLocation } from 'react-router-dom';
3
  import { ThemeProvider, CssBaseline } from '@mui/material';
4
  import theme from './theme/theme';
5
  import { AppsProvider } from './context/AppsContext';
 
6
 
7
  import Home from './pages/Home';
8
  import Download from './pages/Download';
 
12
  import GettingStarted from './pages/GettingStarted';
13
  import Build from './pages/Build';
14
 
15
+ function ScrollToTop() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  const location = useLocation();
17
 
18
  useEffect(() => {
19
+ // Check for scrollTo query parameter (used for anchor-like behavior with HashRouter)
 
 
 
 
 
 
 
 
 
 
 
 
20
  const params = new URLSearchParams(location.search);
21
  const scrollTo = params.get('scrollTo');
22
+
23
  if (scrollTo) {
24
  // Retry mechanism to wait for element to be rendered
25
  const scrollToElement = (retries = 0) => {
 
27
  if (element) {
28
  element.scrollIntoView({ behavior: 'smooth', block: 'center' });
29
  } else if (retries < 10) {
30
+ // Retry up to 10 times with 100ms interval
31
  setTimeout(() => scrollToElement(retries + 1), 100);
32
  }
33
  };
34
+ // Initial delay for page render
35
  setTimeout(() => scrollToElement(), 300);
36
  } else {
37
+ // Otherwise scroll to top
38
  window.scrollTo({ top: 0, behavior: 'smooth' });
39
  }
40
  }, [location.pathname, location.search]);
 
46
  return (
47
  <ThemeProvider theme={theme}>
48
  <CssBaseline />
49
+ <AppsProvider>
50
+ <HashRouter>
51
+ <ScrollToTop />
52
+ <Routes>
53
+ <Route path="/" element={<Home />} />
54
+ <Route path="/getting-started" element={<GettingStarted />} />
55
+ <Route path="https://huggingface.co/docs/reachy_mini/" element={<Build />} />
56
+ <Route path="/download" element={<Download />} />
57
+ <Route path="https://huggingface.co/docs/reachy_mini/troubleshooting" element={<FAQ />} />
58
+ <Route path="/apps" element={<Apps />} />
59
+ <Route path="/buy" element={<Buy />} />
60
+ </Routes>
61
+ </HashRouter>
62
+ </AppsProvider>
 
 
 
 
 
63
  </ThemeProvider>
64
  );
65
  }
src/components/InstallModal.jsx CHANGED
@@ -8,20 +8,16 @@ import {
8
  DialogContent,
9
  IconButton,
10
  Link,
11
- Tooltip,
12
  Typography,
13
  } from '@mui/material';
14
  import CloseIcon from '@mui/icons-material/Close';
15
  import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
16
- import FavoriteIcon from '@mui/icons-material/Favorite';
17
  import VerifiedIcon from '@mui/icons-material/Verified';
18
  import DownloadIcon from '@mui/icons-material/Download';
19
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
20
  import ComputerIcon from '@mui/icons-material/Computer';
21
- import { useAuth } from '../context/AuthContext';
22
 
23
  function InstallModal({ open, onClose, app }) {
24
- const { isLoggedIn, isSpaceLiked, toggleLike } = useAuth();
25
  // Detect Linux users
26
  const isLinux = useMemo(() => {
27
  if (typeof navigator === 'undefined') return false;
@@ -33,18 +29,16 @@ function InstallModal({ open, onClose, app }) {
33
  if (!app) return null;
34
 
35
  const appName = app.name || app.id?.split('/').pop();
36
- const cardData = app.extra?.cardData || {};
37
  const emoji = cardData.emoji || '📦';
38
  const description = cardData.short_description || app.description || 'No description';
39
  const deepLinkUrl = `reachymini://install/${appName}`;
40
- const spaceUrl = app.url || `https://huggingface.co/spaces/${app.id}`;
41
 
42
- const author = app.extra?.author || app.id?.split('/')?.[0] || null;
43
  const isOfficial = app.isOfficial;
44
- const baseLikes = app.extra?.likes || 0;
45
- const isLiked = isSpaceLiked(app.id);
46
- const displayedLikes = baseLikes + (isLiked ? 1 : 0);
47
- const lastModified = app.extra?.lastModified || null;
48
  const formattedDate = lastModified
49
  ? new Date(lastModified).toLocaleDateString('en-US', {
50
  month: 'short',
@@ -170,47 +164,10 @@ function InstallModal({ open, onClose, app }) {
170
 
171
  {/* Stats row */}
172
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 1 }}>
173
- <Tooltip
174
- title={isLoggedIn ? '' : 'Sign in to like this app'}
175
- arrow
176
- placement="top"
177
- disableHoverListener={isLoggedIn}
178
- >
179
- <Box
180
- component="button"
181
- onClick={() => toggleLike(app.id)}
182
- sx={{
183
- display: 'flex',
184
- alignItems: 'center',
185
- gap: 0.5,
186
- border: 'none',
187
- bgcolor: 'transparent',
188
- cursor: 'pointer',
189
- p: 0.5,
190
- m: -0.5,
191
- borderRadius: '6px',
192
- transition: 'all 0.15s ease',
193
- '&:hover': {
194
- bgcolor: 'rgba(236, 72, 153, 0.08)',
195
- },
196
- }}
197
- >
198
- {isLiked ? (
199
- <FavoriteIcon sx={{ fontSize: 14, color: '#ec4899' }} />
200
- ) : (
201
- <FavoriteBorderIcon sx={{ fontSize: 14, color: '#999' }} />
202
- )}
203
- <Typography
204
- sx={{
205
- fontSize: 12,
206
- color: isLiked ? '#ec4899' : '#999',
207
- fontWeight: isLiked ? 600 : 400,
208
- }}
209
- >
210
- {displayedLikes}
211
- </Typography>
212
- </Box>
213
- </Tooltip>
214
  {formattedDate && (
215
  <Typography sx={{ fontSize: 12, color: '#aaa' }}>
216
  Updated {formattedDate}
 
8
  DialogContent,
9
  IconButton,
10
  Link,
 
11
  Typography,
12
  } from '@mui/material';
13
  import CloseIcon from '@mui/icons-material/Close';
14
  import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
 
15
  import VerifiedIcon from '@mui/icons-material/Verified';
16
  import DownloadIcon from '@mui/icons-material/Download';
17
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
18
  import ComputerIcon from '@mui/icons-material/Computer';
 
19
 
20
  function InstallModal({ open, onClose, app }) {
 
21
  // Detect Linux users
22
  const isLinux = useMemo(() => {
23
  if (typeof navigator === 'undefined') return false;
 
29
  if (!app) return null;
30
 
31
  const appName = app.name || app.id?.split('/').pop();
32
+ const cardData = app.cardData || {};
33
  const emoji = cardData.emoji || '📦';
34
  const description = cardData.short_description || app.description || 'No description';
35
  const deepLinkUrl = `reachymini://install/${appName}`;
36
+ const spaceUrl = `https://huggingface.co/spaces/${app.id}`;
37
 
38
+ const author = app.id?.split('/')?.[0] || app.author || null;
39
  const isOfficial = app.isOfficial;
40
+ const likes = app.likes || 0;
41
+ const lastModified = app.lastModified || app.createdAt || null;
 
 
42
  const formattedDate = lastModified
43
  ? new Date(lastModified).toLocaleDateString('en-US', {
44
  month: 'short',
 
164
 
165
  {/* Stats row */}
166
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 1 }}>
167
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
168
+ <FavoriteBorderIcon sx={{ fontSize: 14, color: '#999' }} />
169
+ <Typography sx={{ fontSize: 12, color: '#999' }}>{likes}</Typography>
170
+ </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  {formattedDate && (
172
  <Typography sx={{ fontSize: 12, color: '#aaa' }}>
173
  Updated {formattedDate}
src/context/AppsContext.jsx CHANGED
@@ -11,7 +11,6 @@ const FALLBACK_OFFICIAL_URL = 'https://huggingface.co/datasets/pollen-robotics/r
11
  const FALLBACK_LIMIT = 1000;
12
 
13
  // Fallback: fetch directly from HuggingFace API (for dev mode)
14
- // Returns same format as server (desktop-compatible)
15
  async function fetchAppsDirectFromHF() {
16
  console.log('[AppsContext] Fallback: fetching directly from HuggingFace API');
17
 
@@ -31,42 +30,24 @@ async function fetchAppsDirectFromHF() {
31
  }
32
  const allSpaces = await spacesResponse.json();
33
 
34
- // Build apps list in desktop-compatible format
35
  const allApps = allSpaces.map(space => {
36
  const spaceId = space.id || '';
37
  const tags = space.tags || [];
38
  const isOfficial = officialSet.has(spaceId.toLowerCase());
39
  const isPythonApp = tags.includes('reachy_mini_python_app');
40
- const author = spaceId.split('/')[0];
41
- const name = spaceId.split('/').pop();
42
 
43
  return {
44
- // Core fields
45
  id: spaceId,
46
- name,
47
  description: space.cardData?.short_description || '',
48
- url: `https://huggingface.co/spaces/${spaceId}`,
49
- source_kind: 'hf_space',
 
 
50
  isOfficial,
51
-
52
- // Extra metadata (desktop-compatible structure)
53
- extra: {
54
- id: spaceId,
55
- author,
56
- likes: space.likes || 0,
57
- downloads: space.downloads || 0,
58
- lastModified: space.lastModified,
59
- runtime: space.runtime || null,
60
- tags,
61
- isPythonApp,
62
- cardData: {
63
- emoji: space.cardData?.emoji || (isPythonApp ? '📦' : '🌐'),
64
- short_description: space.cardData?.short_description || '',
65
- sdk: space.cardData?.sdk || null,
66
- tags: space.cardData?.tags || [],
67
- ...space.cardData,
68
- },
69
- },
70
  };
71
  });
72
 
@@ -75,7 +56,7 @@ async function fetchAppsDirectFromHF() {
75
  if (a.isOfficial !== b.isOfficial) {
76
  return a.isOfficial ? -1 : 1;
77
  }
78
- return (b.extra.likes || 0) - (a.extra.likes || 0);
79
  });
80
 
81
  return allApps;
 
11
  const FALLBACK_LIMIT = 1000;
12
 
13
  // Fallback: fetch directly from HuggingFace API (for dev mode)
 
14
  async function fetchAppsDirectFromHF() {
15
  console.log('[AppsContext] Fallback: fetching directly from HuggingFace API');
16
 
 
30
  }
31
  const allSpaces = await spacesResponse.json();
32
 
33
+ // Build apps list
34
  const allApps = allSpaces.map(space => {
35
  const spaceId = space.id || '';
36
  const tags = space.tags || [];
37
  const isOfficial = officialSet.has(spaceId.toLowerCase());
38
  const isPythonApp = tags.includes('reachy_mini_python_app');
 
 
39
 
40
  return {
 
41
  id: spaceId,
42
+ name: spaceId.split('/').pop(),
43
  description: space.cardData?.short_description || '',
44
+ cardData: space.cardData || {},
45
+ likes: space.likes || 0,
46
+ lastModified: space.lastModified,
47
+ author: spaceId.split('/')[0],
48
  isOfficial,
49
+ isPythonApp,
50
+ tags,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  };
52
  });
53
 
 
56
  if (a.isOfficial !== b.isOfficial) {
57
  return a.isOfficial ? -1 : 1;
58
  }
59
+ return (b.likes || 0) - (a.likes || 0);
60
  });
61
 
62
  return allApps;
src/context/AuthContext.jsx DELETED
@@ -1,248 +0,0 @@
1
- import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
2
- import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub';
3
-
4
- const AuthContext = createContext(null);
5
-
6
- const HF_API = 'https://huggingface.co';
7
-
8
- // Whether we're running inside an iframe (i.e. embedded on huggingface.co)
9
- const isInIframe = typeof window !== 'undefined' && window.parent !== window;
10
-
11
- /**
12
- * Fetch OAuth config (clientId, scopes) from our Express server.
13
- * Docker Spaces don't auto-inject window.huggingface.variables like static Spaces,
14
- * so we expose OAUTH_CLIENT_ID via a server endpoint.
15
- */
16
- async function fetchOAuthConfig() {
17
- try {
18
- const response = await fetch('/api/oauth-config');
19
- if (!response.ok) return null;
20
- return await response.json();
21
- } catch {
22
- return null;
23
- }
24
- }
25
-
26
- /**
27
- * Fetch all space IDs liked by a user.
28
- * Returns a Set of lowercase space IDs (e.g. "pollen-robotics/reachy-mini-teleop").
29
- */
30
- async function fetchUserLikedSpaces(username) {
31
- try {
32
- const response = await fetch(`${HF_API}/api/users/${username}/likes`);
33
- if (!response.ok) return new Set();
34
- const likes = await response.json();
35
- // Filter only spaces and return their repo names as a Set
36
- return new Set(
37
- likes
38
- .filter((item) => item.repo?.type === 'space')
39
- .map((item) => item.repo.name.toLowerCase())
40
- );
41
- } catch (err) {
42
- console.error('[Auth] Failed to fetch user likes:', err);
43
- return new Set();
44
- }
45
- }
46
-
47
- /**
48
- * Send a like request to the HF parent frame via postMessage.
49
- * Returns a Promise that resolves with the response data.
50
- * The parent frame (huggingface.co) handles auth via session cookies.
51
- */
52
- function likeViaPostMessage(spaceId) {
53
- return new Promise((resolve, reject) => {
54
- const timeout = setTimeout(() => {
55
- window.removeEventListener('message', handler);
56
- reject(new Error('Like request timed out'));
57
- }, 5000);
58
-
59
- function handler(event) {
60
- if (event.data?.type !== 'LIKE_REPO_RESPONSE') return;
61
- clearTimeout(timeout);
62
- window.removeEventListener('message', handler);
63
- resolve(event.data);
64
- }
65
-
66
- window.addEventListener('message', handler);
67
- window.parent.postMessage(
68
- { type: 'LIKE_REPO_REQUEST', repo: { type: 'space', name: spaceId } },
69
- '*'
70
- );
71
- });
72
- }
73
-
74
- // Provider component
75
- export function AuthProvider({ children }) {
76
- const [user, setUser] = useState(null); // { name, avatarUrl }
77
- const [likedSpaceIds, setLikedSpaceIds] = useState(new Set());
78
- const [isLoading, setIsLoading] = useState(true);
79
- const [oauthConfig, setOauthConfig] = useState(null); // { clientId, scopes }
80
- const pendingLikes = useRef(new Set()); // Track in-flight like requests
81
-
82
- // On mount: fetch OAuth config + check if user just completed OAuth redirect
83
- useEffect(() => {
84
- async function init() {
85
- try {
86
- // Fetch OAuth config from server (needed for Docker Spaces)
87
- const config = await fetchOAuthConfig();
88
- if (config?.clientId) {
89
- setOauthConfig(config);
90
- console.log('[Auth] OAuth config loaded (clientId available)');
91
- } else {
92
- console.log('[Auth] OAuth not available (no clientId from server)');
93
- }
94
-
95
- // Check if user just completed OAuth redirect
96
- const oauthResult = await oauthHandleRedirectIfPresent();
97
- if (oauthResult) {
98
- const { userInfo } = oauthResult;
99
- const userData = {
100
- name: userInfo.name,
101
- preferredUsername: userInfo.preferred_username || userInfo.name,
102
- avatarUrl: userInfo.picture,
103
- };
104
- setUser(userData);
105
-
106
- // Fetch user's liked spaces
107
- const likes = await fetchUserLikedSpaces(userData.preferredUsername);
108
- setLikedSpaceIds(likes);
109
-
110
- console.log(
111
- `[Auth] Logged in as ${userData.preferredUsername}, ${likes.size} liked spaces`
112
- );
113
- }
114
- } catch (err) {
115
- console.error('[Auth] Init error:', err);
116
- } finally {
117
- setIsLoading(false);
118
- }
119
- }
120
-
121
- init();
122
- }, []);
123
-
124
- // Login: redirect to HF OAuth (passing clientId from server config)
125
- const login = useCallback(async () => {
126
- if (!oauthConfig?.clientId) {
127
- console.warn('[Auth] Cannot login: OAuth not configured');
128
- return;
129
- }
130
-
131
- try {
132
- const loginUrl = await oauthLoginUrl({
133
- clientId: oauthConfig.clientId,
134
- scopes: oauthConfig.scopes || 'openid profile',
135
- });
136
- window.location.href = loginUrl;
137
- } catch (err) {
138
- console.error('[Auth] Failed to get OAuth URL:', err);
139
- }
140
- }, [oauthConfig]);
141
-
142
- // Logout: clear state
143
- const logout = useCallback(() => {
144
- setUser(null);
145
- setLikedSpaceIds(new Set());
146
- }, []);
147
-
148
- // Check if a space is liked
149
- const isSpaceLiked = useCallback(
150
- (spaceId) => {
151
- return likedSpaceIds.has(spaceId?.toLowerCase());
152
- },
153
- [likedSpaceIds]
154
- );
155
-
156
- /**
157
- * Like a space via the HF parent frame postMessage protocol.
158
- * The parent (huggingface.co) handles auth via session cookies.
159
- * Like-only (no unlike) — if already liked, it's a no-op.
160
- */
161
- const toggleLike = useCallback(
162
- async (spaceId) => {
163
- const spaceIdLower = spaceId?.toLowerCase();
164
- if (!spaceIdLower) return;
165
-
166
- // Already liked → no-op (postMessage only supports like, not unlike)
167
- if (likedSpaceIds.has(spaceIdLower)) return;
168
-
169
- // Not in iframe → can't use postMessage, prompt OAuth login as fallback
170
- if (!isInIframe) {
171
- console.warn('[Auth] Not in iframe, postMessage unavailable');
172
- return;
173
- }
174
-
175
- // Prevent duplicate requests
176
- if (pendingLikes.current.has(spaceIdLower)) return;
177
- pendingLikes.current.add(spaceIdLower);
178
-
179
- // Optimistic update
180
- setLikedSpaceIds((prev) => {
181
- const next = new Set(prev);
182
- next.add(spaceIdLower);
183
- return next;
184
- });
185
-
186
- try {
187
- const result = await likeViaPostMessage(spaceId);
188
-
189
- if (result.error) {
190
- throw new Error(`${result.error.code}: ${result.error.message}`);
191
- }
192
-
193
- if (result.status === 'not_logged_in') {
194
- // User not logged in to HF → revert and prompt login
195
- throw new Error('not_logged_in');
196
- }
197
-
198
- // "done" or "already_liked" → success
199
- console.log(`[Auth] Liked ${spaceId}: ${result.status}`, result.likes != null ? `(${result.likes} likes)` : '');
200
- } catch (err) {
201
- console.error(`[Auth] Failed to like ${spaceId}:`, err.message);
202
-
203
- // Revert optimistic update
204
- setLikedSpaceIds((prev) => {
205
- const reverted = new Set(prev);
206
- reverted.delete(spaceIdLower);
207
- return reverted;
208
- });
209
-
210
- // If not logged in, prompt OAuth login
211
- if (err.message === 'not_logged_in') {
212
- login();
213
- }
214
- } finally {
215
- pendingLikes.current.delete(spaceIdLower);
216
- }
217
- },
218
- [likedSpaceIds, login]
219
- );
220
-
221
- return (
222
- <AuthContext.Provider
223
- value={{
224
- user,
225
- isLoggedIn: !!user,
226
- isLoading,
227
- isOAuthAvailable: !!oauthConfig?.clientId,
228
- isInIframe,
229
- likedSpaceIds,
230
- login,
231
- logout,
232
- isSpaceLiked,
233
- toggleLike,
234
- }}
235
- >
236
- {children}
237
- </AuthContext.Provider>
238
- );
239
- }
240
-
241
- // Hook to use auth context
242
- export function useAuth() {
243
- const context = useContext(AuthContext);
244
- if (!context) {
245
- throw new Error('useAuth must be used within an AuthProvider');
246
- }
247
- return context;
248
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/pages/Apps.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react';
2
  import {
3
  Box,
4
  Container,
@@ -12,87 +12,29 @@ import {
12
  Link,
13
  IconButton,
14
  Button,
15
- Tooltip,
16
  } from '@mui/material';
17
  import SearchIcon from '@mui/icons-material/Search';
18
  import CloseIcon from '@mui/icons-material/Close';
19
  import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
20
- import FavoriteIcon from '@mui/icons-material/Favorite';
21
  import AccessTimeIcon from '@mui/icons-material/AccessTime';
22
  import VerifiedIcon from '@mui/icons-material/Verified';
23
  import DownloadIcon from '@mui/icons-material/Download';
24
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
25
- import LogoutIcon from '@mui/icons-material/Logout';
26
  import Layout from '../components/Layout';
27
  import ReachiesCarousel from '../components/ReachiesCarousel';
28
  import { useApps } from '../context/AppsContext';
29
- import { useAuth } from '../context/AuthContext';
30
  import InstallModal from '../components/InstallModal';
31
 
32
- /**
33
- * Render text with highlighted match ranges from Fuse.js.
34
- * indices is an array of [start, end] pairs.
35
- */
36
- function HighlightText({ text, indices }) {
37
- if (!text) return null;
38
- if (!indices || indices.length === 0) return text;
39
-
40
- // Only keep matches that span at least 2 characters
41
- const significant = indices.filter(([start, end]) => end - start >= 1);
42
- if (significant.length === 0) return text;
43
-
44
- // Merge overlapping / adjacent ranges and sort
45
- const sorted = [...significant].sort((a, b) => a[0] - b[0]);
46
- const merged = [sorted[0]];
47
- for (let i = 1; i < sorted.length; i++) {
48
- const prev = merged[merged.length - 1];
49
- if (sorted[i][0] <= prev[1] + 1) {
50
- prev[1] = Math.max(prev[1], sorted[i][1]);
51
- } else {
52
- merged.push(sorted[i]);
53
- }
54
- }
55
-
56
- const parts = [];
57
- let cursor = 0;
58
- for (const [start, end] of merged) {
59
- if (cursor < start) {
60
- parts.push(<span key={`t${cursor}`}>{text.slice(cursor, start)}</span>);
61
- }
62
- parts.push(
63
- <span
64
- key={`h${start}`}
65
- style={{
66
- backgroundColor: 'rgba(255, 149, 0, 0.18)',
67
- color: '#b36b00',
68
- borderRadius: 2,
69
- padding: '0 1px',
70
- }}
71
- >
72
- {text.slice(start, end + 1)}
73
- </span>
74
- );
75
- cursor = end + 1;
76
- }
77
- if (cursor < text.length) {
78
- parts.push(<span key={`t${cursor}`}>{text.slice(cursor)}</span>);
79
- }
80
- return <>{parts}</>;
81
- }
82
-
83
- // App Card Component (memoized to avoid re-renders when only search changes)
84
- const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLike, isLoggedIn, matchData }) {
85
  const isOfficial = app.isOfficial;
86
- const isPythonApp = app.extra?.isPythonApp !== false; // Default to true for backwards compatibility
87
- const cardData = app.extra?.cardData || {};
88
- const author = app.extra?.author || app.id?.split('/')?.[0] || null;
89
- const baseLikes = app.extra?.likes || 0;
90
- const lastModified = app.extra?.lastModified || null;
91
  const emoji = cardData.emoji || (isPythonApp ? '📦' : '🌐');
92
- const spaceUrl = app.url || `https://huggingface.co/spaces/${app.id}`;
93
-
94
- // Compute displayed likes: adjust based on like state vs original data
95
- const displayedLikes = baseLikes + (isLiked ? 1 : 0);
96
 
97
  const formattedDate = lastModified
98
  ? new Date(lastModified).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
@@ -177,7 +119,7 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
177
  whiteSpace: 'nowrap',
178
  }}
179
  >
180
- <HighlightText text={author} indices={matchData?._searchAuthor} />
181
  </Typography>
182
  </Box>
183
  )}
@@ -226,52 +168,19 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
226
  )}
227
  </Box>
228
 
229
- {/* Likes - Interactive */}
230
- <Tooltip
231
- title={isLoggedIn ? '' : 'Sign in to like this app'}
232
- arrow
233
- placement="top"
234
- disableHoverListener={isLoggedIn}
235
- >
236
- <Box
237
- component="button"
238
- onClick={(e) => {
239
- e.preventDefault();
240
- e.stopPropagation();
241
- onToggleLike?.(app.id);
242
- }}
243
  sx={{
244
- display: 'flex',
245
- alignItems: 'center',
246
- gap: 0.5,
247
- flexShrink: 0,
248
- border: 'none',
249
- bgcolor: 'transparent',
250
- cursor: 'pointer',
251
- p: 0.5,
252
- borderRadius: '6px',
253
- transition: 'all 0.15s ease',
254
- '&:hover': {
255
- bgcolor: 'rgba(236, 72, 153, 0.08)',
256
- },
257
  }}
258
  >
259
- {isLiked ? (
260
- <FavoriteIcon sx={{ fontSize: 16, color: '#ec4899' }} />
261
- ) : (
262
- <FavoriteBorderIcon sx={{ fontSize: 16, color: '#666666' }} />
263
- )}
264
- <Typography
265
- sx={{
266
- fontSize: 12,
267
- fontWeight: 600,
268
- color: isLiked ? '#ec4899' : '#666666',
269
- }}
270
- >
271
- {displayedLikes}
272
- </Typography>
273
- </Box>
274
- </Tooltip>
275
  </Box>
276
 
277
  {/* Content */}
@@ -298,10 +207,7 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
298
  flex: 1,
299
  }}
300
  >
301
- <HighlightText
302
- text={app.name || app.id?.split('/').pop()}
303
- indices={matchData?.name}
304
- />
305
  </Typography>
306
 
307
  <Typography
@@ -331,10 +237,7 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
331
  flex: 1,
332
  }}
333
  >
334
- <HighlightText
335
- text={cardData.short_description || app.description || 'No description'}
336
- indices={matchData?._searchDescription}
337
- />
338
  </Typography>
339
 
340
  {/* Date + Install Button */}
@@ -385,115 +288,14 @@ const AppCard = memo(function AppCard({ app, onInstallClick, isLiked, onToggleLi
385
  </Box>
386
  </Box>
387
  );
388
- });
389
-
390
- // Isolated search input — typing only re-renders this component, not the whole page
391
- const SearchInput = memo(function SearchInput({ onSearch }) {
392
- const [value, setValue] = useState('');
393
- const debounceRef = useRef(null);
394
-
395
- useEffect(() => {
396
- clearTimeout(debounceRef.current);
397
- if (!value.trim()) {
398
- onSearch('');
399
- return;
400
- }
401
- debounceRef.current = setTimeout(() => onSearch(value.trim()), 200);
402
- return () => clearTimeout(debounceRef.current);
403
- }, [value, onSearch]);
404
-
405
- return (
406
- <>
407
- <SearchIcon sx={{ fontSize: 22, color: '#999' }} />
408
- <InputBase
409
- placeholder="Search apps by name, description, tags..."
410
- value={value}
411
- onChange={(e) => setValue(e.target.value)}
412
- sx={{
413
- flex: 1,
414
- fontSize: 15,
415
- fontWeight: 500,
416
- color: '#333',
417
- '& input::placeholder': {
418
- color: '#999',
419
- opacity: 1,
420
- },
421
- }}
422
- />
423
- {value && (
424
- <IconButton
425
- onClick={() => { setValue(''); onSearch(''); }}
426
- size="small"
427
- sx={{ color: '#999' }}
428
- >
429
- <CloseIcon sx={{ fontSize: 20 }} />
430
- </IconButton>
431
- )}
432
- </>
433
- );
434
- });
435
-
436
- // Tags to exclude from category filters
437
- const EXCLUDED_TAGS = new Set([
438
- 'reachy_mini', 'reachy-mini', 'reachy_mini_python_app',
439
- 'static', 'docker', 'region:us', 'region:eu',
440
- ]);
441
-
442
- // Format a tag name for display (e.g. "reachy_mini_game" → "Reachy Mini Game")
443
- function formatTagName(tag) {
444
- if (tag.startsWith('sdk:')) {
445
- const sdk = tag.replace('sdk:', '');
446
- return sdk.charAt(0).toUpperCase() + sdk.slice(1).toLowerCase();
447
- }
448
- return tag
449
- .replace(/_/g, ' ')
450
- .replace(/\b\w/g, (l) => l.toUpperCase());
451
  }
452
 
453
  // Main Apps Page
454
  export default function Apps() {
455
  // Get apps from context (cached globally)
456
  const { apps, loading, error } = useApps();
457
- const { user, isLoggedIn, isOAuthAvailable, login, logout, isSpaceLiked, toggleLike } = useAuth();
458
  const [officialOnly, setOfficialOnly] = useState(false);
459
- const [selectedCategory, setSelectedCategory] = useState(null);
460
- const [searchResults, setSearchResults] = useState(null); // null = no search, [] = no matches
461
- const [isSearching, setIsSearching] = useState(false);
462
- const workerRef = useRef(null);
463
-
464
- // Initialize search Web Worker
465
- useEffect(() => {
466
- workerRef.current = new Worker(
467
- new URL('../workers/searchWorker.js', import.meta.url),
468
- { type: 'module' }
469
- );
470
-
471
- workerRef.current.onmessage = (e) => {
472
- if (e.data.type === 'RESULTS') {
473
- setSearchResults(e.data.results);
474
- }
475
- };
476
-
477
- return () => workerRef.current?.terminate();
478
- }, []);
479
-
480
- // Send apps to worker to build index whenever apps change
481
- useEffect(() => {
482
- if (workerRef.current && apps.length > 0) {
483
- workerRef.current.postMessage({ type: 'INDEX', apps });
484
- }
485
- }, [apps]);
486
-
487
- // Callback from SearchInput component (already debounced)
488
- const handleSearch = useCallback((query) => {
489
- if (!query) {
490
- setSearchResults(null);
491
- setIsSearching(false);
492
- return;
493
- }
494
- setIsSearching(true);
495
- workerRef.current?.postMessage({ type: 'SEARCH', query });
496
- }, []);
497
 
498
  // Install modal state
499
  const [installModalOpen, setInstallModalOpen] = useState(false);
@@ -509,110 +311,30 @@ export default function Apps() {
509
  setSelectedApp(null);
510
  };
511
 
512
- const handleToggleLike = useCallback(
513
- (spaceId) => {
514
- toggleLike(spaceId);
515
- },
516
- [toggleLike]
517
- );
518
-
519
- // Extract available categories from app tags, sorted by count (top 8)
520
- const categories = useMemo(() => {
521
- const categoryMap = new Map();
522
-
523
- // Use only apps matching current mode (official toggle)
524
- const baseApps = officialOnly ? apps.filter((a) => a.isOfficial) : apps;
525
-
526
- baseApps.forEach((app) => {
527
- const rootTags = app.extra?.tags || [];
528
- const cardDataTags = app.extra?.cardData?.tags || [];
529
- const allTags = [...new Set([...rootTags, ...cardDataTags])];
530
- const sdk = app.extra?.sdk || app.extra?.cardData?.sdk;
531
-
532
- allTags.forEach((tag) => {
533
- if (
534
- tag &&
535
- typeof tag === 'string' &&
536
- !tag.startsWith('region:') &&
537
- !EXCLUDED_TAGS.has(tag.toLowerCase())
538
- ) {
539
- categoryMap.set(tag, (categoryMap.get(tag) || 0) + 1);
540
- }
541
- });
542
-
543
- // Add SDK as category if not already covered by a tag
544
- if (sdk && typeof sdk === 'string') {
545
- const hasMatchingTag = allTags.some(
546
- (t) => t && typeof t === 'string' && t.toLowerCase() === sdk.toLowerCase()
547
- );
548
- if (!hasMatchingTag) {
549
- const sdkKey = `sdk:${sdk}`;
550
- categoryMap.set(sdkKey, (categoryMap.get(sdkKey) || 0) + 1);
551
- }
552
- }
553
- });
554
-
555
- return Array.from(categoryMap.entries())
556
- .map(([name, count]) => ({ name, count }))
557
- .sort((a, b) => (b.count !== a.count ? b.count - a.count : a.name.localeCompare(b.name)))
558
- .slice(0, 8);
559
- }, [apps, officialOnly]);
560
-
561
- // Filter apps based on worker search results, official toggle, and category
562
  const filteredApps = useMemo(() => {
563
  let result = apps;
564
 
565
  // Filter by official
566
  if (officialOnly) {
567
- result = result.filter((app) => app.isOfficial === true);
568
- }
569
-
570
- // Filter by category
571
- if (selectedCategory) {
572
- result = result.filter((app) => {
573
- const rootTags = app.extra?.tags || [];
574
- const cardDataTags = app.extra?.cardData?.tags || [];
575
- const allTags = [...new Set([...rootTags, ...cardDataTags])];
576
- const sdk = app.extra?.sdk || app.extra?.cardData?.sdk;
577
-
578
- if (selectedCategory.startsWith('sdk:')) {
579
- return sdk === selectedCategory.replace('sdk:', '');
580
- }
581
- const tagMatch = allTags.some(
582
- (t) => t && typeof t === 'string' && t.toLowerCase() === selectedCategory.toLowerCase()
583
- );
584
- const sdkMatch =
585
- sdk && typeof sdk === 'string' && sdk.toLowerCase() === selectedCategory.toLowerCase();
586
- return tagMatch || sdkMatch;
587
- });
588
  }
589
 
590
- // Apply fuzzy search results from worker
591
- if (searchResults !== null) {
592
- const scoreMap = new Map(searchResults.map((r) => [r.id, r.score]));
593
- const matchedIds = new Set(searchResults.map((r) => r.id));
594
- result = result.filter((app) => matchedIds.has(app.id));
595
- result.sort((a, b) => (scoreMap.get(a.id) || 1) - (scoreMap.get(b.id) || 1));
596
- return result;
 
 
597
  }
598
 
599
- // Default sort: by likes (descending)
600
- result.sort((a, b) => (b.extra?.likes || 0) - (a.extra?.likes || 0));
601
-
602
  return result;
603
- }, [apps, officialOnly, selectedCategory, searchResults]);
604
-
605
- // Build a map of app ID → match highlight data
606
- const matchDataMap = useMemo(() => {
607
- if (!searchResults) return null;
608
- const map = new Map();
609
- for (const r of searchResults) {
610
- map.set(r.id, r.matches);
611
- }
612
- return map;
613
- }, [searchResults]);
614
 
615
- const isFiltered = searchResults !== null || officialOnly || selectedCategory;
616
 
617
  return (
618
  <Layout transparentHeader>
@@ -735,7 +457,7 @@ export default function Apps() {
735
  </Box>
736
 
737
  {/* Search Section */}
738
- <Container maxWidth="lg" sx={{ mt: -4, mb: 1, position: 'relative', zIndex: 10 }}>
739
  <Box
740
  sx={{
741
  display: 'flex',
@@ -743,14 +465,39 @@ export default function Apps() {
743
  gap: 2,
744
  px: 3,
745
  py: 2,
746
- mb: 2,
747
  borderRadius: '16px',
748
  bgcolor: 'white',
749
  boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)',
750
  border: '1px solid rgba(0, 0, 0, 0.06)',
751
  }}
752
  >
753
- <SearchInput onSearch={handleSearch} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
 
755
  {/* Separator */}
756
  <Box sx={{ width: '1px', height: '24px', bgcolor: 'rgba(0, 0, 0, 0.1)' }} />
@@ -795,192 +542,9 @@ export default function Apps() {
795
  }
796
  sx={{ m: 0 }}
797
  />
798
-
799
- {/* Auth: Login / User (only show when OAuth is available) */}
800
- {isOAuthAvailable && (
801
- <Box sx={{ width: '1px', height: '24px', bgcolor: 'rgba(0, 0, 0, 0.1)' }} />
802
- )}
803
-
804
- {isLoggedIn ? (
805
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
806
- <Avatar
807
- src={user?.avatarUrl}
808
- sx={{
809
- width: 28,
810
- height: 28,
811
- fontSize: 12,
812
- fontWeight: 600,
813
- bgcolor: '#FF9500',
814
- }}
815
- >
816
- {user?.name?.charAt(0)?.toUpperCase()}
817
- </Avatar>
818
- <Typography
819
- sx={{
820
- fontSize: 13,
821
- fontWeight: 600,
822
- color: '#333',
823
- maxWidth: 100,
824
- overflow: 'hidden',
825
- textOverflow: 'ellipsis',
826
- whiteSpace: 'nowrap',
827
- }}
828
- >
829
- {user?.preferredUsername || user?.name}
830
- </Typography>
831
- <Tooltip title="Sign out">
832
- <IconButton onClick={logout} size="small" sx={{ color: '#999' }}>
833
- <LogoutIcon sx={{ fontSize: 18 }} />
834
- </IconButton>
835
- </Tooltip>
836
- </Box>
837
- ) : isOAuthAvailable ? (
838
- <Button
839
- onClick={login}
840
- variant="text"
841
- size="small"
842
- sx={{
843
- textTransform: 'none',
844
- fontWeight: 600,
845
- fontSize: 13,
846
- color: '#333',
847
- gap: 0.75,
848
- px: 1.5,
849
- borderRadius: '8px',
850
- whiteSpace: 'nowrap',
851
- '&:hover': {
852
- bgcolor: 'rgba(0, 0, 0, 0.04)',
853
- },
854
- }}
855
- >
856
- <Box
857
- component="img"
858
- src="/assets/hf-logo.svg"
859
- alt=""
860
- sx={{ height: 16 }}
861
- />
862
- Sign in
863
- </Button>
864
- ) : null}
865
  </Box>
866
  </Container>
867
 
868
- {/* Category Tags */}
869
- {!loading && categories.length > 0 && (
870
- <Container maxWidth="lg" sx={{ mt: 1, mb: 4 }}>
871
- <Box
872
- sx={{
873
- display: 'flex',
874
- alignItems: 'center',
875
- gap: 1.5,
876
- flexWrap: 'wrap',
877
- }}
878
- >
879
- <Typography
880
- sx={{
881
- fontSize: 12,
882
- fontWeight: 600,
883
- color: '#999',
884
- textTransform: 'uppercase',
885
- letterSpacing: '0.05em',
886
- }}
887
- >
888
- Tags
889
- </Typography>
890
-
891
- {/* "All" chip */}
892
- <Chip
893
- label={
894
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
895
- <span>All</span>
896
- <Typography
897
- component="span"
898
- sx={{
899
- fontSize: 11,
900
- fontWeight: 600,
901
- color: !selectedCategory ? '#FF9500' : '#999',
902
- opacity: 0.8,
903
- }}
904
- >
905
- ({officialOnly ? apps.filter((a) => a.isOfficial).length : apps.length})
906
- </Typography>
907
- </Box>
908
- }
909
- onClick={() => setSelectedCategory(null)}
910
- size="small"
911
- sx={{
912
- height: 28,
913
- fontSize: 12,
914
- fontWeight: !selectedCategory ? 700 : 500,
915
- bgcolor: !selectedCategory
916
- ? 'rgba(255, 149, 0, 0.12)'
917
- : 'rgba(0, 0, 0, 0.04)',
918
- color: !selectedCategory ? '#FF9500' : '#666',
919
- border: !selectedCategory
920
- ? '1px solid rgba(255, 149, 0, 0.4)'
921
- : '1px solid rgba(0, 0, 0, 0.1)',
922
- cursor: 'pointer',
923
- transition: 'all 0.15s ease',
924
- '&:hover': {
925
- bgcolor: !selectedCategory
926
- ? 'rgba(255, 149, 0, 0.18)'
927
- : 'rgba(0, 0, 0, 0.08)',
928
- },
929
- '& .MuiChip-label': { px: 1.5 },
930
- }}
931
- />
932
-
933
- {/* Category chips */}
934
- {categories.map((cat) => {
935
- const isSelected = selectedCategory === cat.name;
936
- return (
937
- <Chip
938
- key={cat.name}
939
- label={
940
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
941
- <span>{formatTagName(cat.name)}</span>
942
- <Typography
943
- component="span"
944
- sx={{
945
- fontSize: 11,
946
- fontWeight: 600,
947
- color: isSelected ? '#FF9500' : '#999',
948
- opacity: 0.8,
949
- }}
950
- >
951
- ({cat.count})
952
- </Typography>
953
- </Box>
954
- }
955
- onClick={() => setSelectedCategory(isSelected ? null : cat.name)}
956
- size="small"
957
- sx={{
958
- height: 28,
959
- fontSize: 12,
960
- fontWeight: isSelected ? 700 : 500,
961
- bgcolor: isSelected
962
- ? 'rgba(255, 149, 0, 0.12)'
963
- : 'rgba(0, 0, 0, 0.04)',
964
- color: isSelected ? '#FF9500' : '#666',
965
- border: isSelected
966
- ? '1px solid rgba(255, 149, 0, 0.4)'
967
- : '1px solid rgba(0, 0, 0, 0.1)',
968
- cursor: 'pointer',
969
- transition: 'all 0.15s ease',
970
- '&:hover': {
971
- bgcolor: isSelected
972
- ? 'rgba(255, 149, 0, 0.18)'
973
- : 'rgba(0, 0, 0, 0.08)',
974
- },
975
- '& .MuiChip-label': { px: 1.5 },
976
- }}
977
- />
978
- );
979
- })}
980
- </Box>
981
- </Container>
982
- )}
983
-
984
  {/* Apps Grid */}
985
  <Container maxWidth="lg" sx={{ pb: 10 }}>
986
  {loading ? (
@@ -1018,10 +582,6 @@ export default function Apps() {
1018
  key={app.id || index}
1019
  app={app}
1020
  onInstallClick={handleInstallClick}
1021
- isLiked={isSpaceLiked(app.id)}
1022
- onToggleLike={handleToggleLike}
1023
- isLoggedIn={isLoggedIn}
1024
- matchData={matchDataMap?.get(app.id) || null}
1025
  />
1026
  ))}
1027
  </Box>
 
1
+ import { useState, useMemo } from 'react';
2
  import {
3
  Box,
4
  Container,
 
12
  Link,
13
  IconButton,
14
  Button,
 
15
  } from '@mui/material';
16
  import SearchIcon from '@mui/icons-material/Search';
17
  import CloseIcon from '@mui/icons-material/Close';
18
  import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
 
19
  import AccessTimeIcon from '@mui/icons-material/AccessTime';
20
  import VerifiedIcon from '@mui/icons-material/Verified';
21
  import DownloadIcon from '@mui/icons-material/Download';
22
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
 
23
  import Layout from '../components/Layout';
24
  import ReachiesCarousel from '../components/ReachiesCarousel';
25
  import { useApps } from '../context/AppsContext';
 
26
  import InstallModal from '../components/InstallModal';
27
 
28
+ // App Card Component
29
+ function AppCard({ app, onInstallClick }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  const isOfficial = app.isOfficial;
31
+ const isPythonApp = app.isPythonApp !== false; // Default to true for backwards compatibility
32
+ const cardData = app.cardData || {};
33
+ const author = app.id?.split('/')?.[0] || app.author || null;
34
+ const likes = app.likes || 0;
35
+ const lastModified = app.lastModified || app.createdAt || null;
36
  const emoji = cardData.emoji || (isPythonApp ? '📦' : '🌐');
37
+ const spaceUrl = `https://huggingface.co/spaces/${app.id}`;
 
 
 
38
 
39
  const formattedDate = lastModified
40
  ? new Date(lastModified).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
 
119
  whiteSpace: 'nowrap',
120
  }}
121
  >
122
+ {author}
123
  </Typography>
124
  </Box>
125
  )}
 
168
  )}
169
  </Box>
170
 
171
+ {/* Likes */}
172
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
173
+ <FavoriteBorderIcon sx={{ fontSize: 16, color: '#666666' }} />
174
+ <Typography
 
 
 
 
 
 
 
 
 
 
175
  sx={{
176
+ fontSize: 12,
177
+ fontWeight: 600,
178
+ color: '#666666',
 
 
 
 
 
 
 
 
 
 
179
  }}
180
  >
181
+ {likes}
182
+ </Typography>
183
+ </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  </Box>
185
 
186
  {/* Content */}
 
207
  flex: 1,
208
  }}
209
  >
210
+ {app.name || app.id?.split('/').pop()}
 
 
 
211
  </Typography>
212
 
213
  <Typography
 
237
  flex: 1,
238
  }}
239
  >
240
+ {cardData.short_description || app.description || 'No description'}
 
 
 
241
  </Typography>
242
 
243
  {/* Date + Install Button */}
 
288
  </Box>
289
  </Box>
290
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  }
292
 
293
  // Main Apps Page
294
  export default function Apps() {
295
  // Get apps from context (cached globally)
296
  const { apps, loading, error } = useApps();
297
+ const [searchQuery, setSearchQuery] = useState('');
298
  const [officialOnly, setOfficialOnly] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
 
300
  // Install modal state
301
  const [installModalOpen, setInstallModalOpen] = useState(false);
 
311
  setSelectedApp(null);
312
  };
313
 
314
+ // Filter apps based on search and official toggle
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  const filteredApps = useMemo(() => {
316
  let result = apps;
317
 
318
  // Filter by official
319
  if (officialOnly) {
320
+ result = result.filter(app => app.isOfficial === true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  }
322
 
323
+ // Filter by search
324
+ if (searchQuery.trim()) {
325
+ const query = searchQuery.toLowerCase();
326
+ result = result.filter(app =>
327
+ app.name?.toLowerCase().includes(query) ||
328
+ app.id?.toLowerCase().includes(query) ||
329
+ app.description?.toLowerCase().includes(query) ||
330
+ app.cardData?.short_description?.toLowerCase().includes(query)
331
+ );
332
  }
333
 
 
 
 
334
  return result;
335
+ }, [apps, searchQuery, officialOnly]);
 
 
 
 
 
 
 
 
 
 
336
 
337
+ const isFiltered = searchQuery.trim() || officialOnly;
338
 
339
  return (
340
  <Layout transparentHeader>
 
457
  </Box>
458
 
459
  {/* Search Section */}
460
+ <Container maxWidth="lg" sx={{ mt: -4, mb: 4, position: 'relative', zIndex: 10 }}>
461
  <Box
462
  sx={{
463
  display: 'flex',
 
465
  gap: 2,
466
  px: 3,
467
  py: 2,
 
468
  borderRadius: '16px',
469
  bgcolor: 'white',
470
  boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)',
471
  border: '1px solid rgba(0, 0, 0, 0.06)',
472
  }}
473
  >
474
+ <SearchIcon sx={{ fontSize: 22, color: '#999' }} />
475
+ <InputBase
476
+ placeholder="Search apps by name or description..."
477
+ value={searchQuery}
478
+ onChange={(e) => setSearchQuery(e.target.value)}
479
+ sx={{
480
+ flex: 1,
481
+ fontSize: 15,
482
+ fontWeight: 500,
483
+ color: '#333',
484
+ '& input::placeholder': {
485
+ color: '#999',
486
+ opacity: 1,
487
+ },
488
+ }}
489
+ />
490
+
491
+ {/* Clear search */}
492
+ {searchQuery && (
493
+ <IconButton
494
+ onClick={() => setSearchQuery('')}
495
+ size="small"
496
+ sx={{ color: '#999' }}
497
+ >
498
+ <CloseIcon sx={{ fontSize: 20 }} />
499
+ </IconButton>
500
+ )}
501
 
502
  {/* Separator */}
503
  <Box sx={{ width: '1px', height: '24px', bgcolor: 'rgba(0, 0, 0, 0.1)' }} />
 
542
  }
543
  sx={{ m: 0 }}
544
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  </Box>
546
  </Container>
547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  {/* Apps Grid */}
549
  <Container maxWidth="lg" sx={{ pb: 10 }}>
550
  {loading ? (
 
582
  key={app.id || index}
583
  app={app}
584
  onInstallClick={handleInstallClick}
 
 
 
 
585
  />
586
  ))}
587
  </Box>
src/pages/Buy.jsx CHANGED
@@ -41,7 +41,7 @@ const products = {
41
  price: 449,
42
  badge: 'Wireless',
43
  badgeColor: '#0ea5e9',
44
- description: 'Self-contained robot with on-board compute. Works wirelessly or wired, perfect for standalone projects and demos. <strong>Ships in 60 days</strong>.',
45
  buyLink: 'https://buy.stripe.com/9B65kFfFlaKFbY34W873G03',
46
  image: '/assets/reachy-wireless.png',
47
  featured: true,
@@ -52,7 +52,7 @@ const products = {
52
  price: 299,
53
  badge: 'Lite',
54
  badgeColor: '#f59e0b',
55
- description: 'Connect to your computer via USB. Same expressive robot, powered by your machine. Ideal for development and learning. <strong>Ships in 30 days</strong>.',
56
  buyLink: 'https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02',
57
  image: '/assets/reachy-lite.png',
58
  featured: false,
@@ -68,7 +68,7 @@ const comparisonFeatures = [
68
  { name: 'Camera', wireless: 'Wide angle', lite: 'Wide angle' },
69
  { name: 'Microphones', wireless: '4 microphones array', lite: '4 microphones array' },
70
  { name: 'Speaker', wireless: '5W speaker', lite: '5W speaker' },
71
- { name: 'On-board Compute', wireless: 'Raspberry Pi CM 4 (16GB storage)', lite: false },
72
  { name: 'Accelerometer', wireless: 'Built-in IMU', lite: false },
73
  { name: 'Wi-Fi Connectivity', wireless: 'Wi-Fi', lite: false },
74
  { name: 'Standalone Mode', wireless: true, lite: false },
@@ -90,7 +90,7 @@ const boxContents = [
90
  const faqItems = [
91
  {
92
  question: 'What is the difference between Wireless and Lite?',
93
- answer: 'The Wireless version includes a Raspberry Pi CM 4 built-in, allowing it to run standalone without a computer. The Lite version connects to your Mac, Linux, or Windows computer via USB and uses your computer for processing. Both versions have the same mechanical design and audio/video capabilities.',
94
  },
95
  {
96
  question: 'How long does assembly take?',
@@ -98,7 +98,7 @@ const faqItems = [
98
  },
99
  {
100
  question: 'What about customs and import taxes?',
101
- answer: 'EU/UK and US/Canada orders ship duty-paid (DDP) no surprise fees on delivery. Other destinations ship DAP, meaning local import duties and taxes may apply upon delivery.',
102
  },
103
  {
104
  question: 'Can I upgrade from Lite to Wireless later?',
@@ -338,7 +338,7 @@ function ProductCardsSection() {
338
  <Stack spacing={1} sx={{ mb: 3 }}>
339
  {key === 'wireless' ? (
340
  <>
341
- <FeatureRow icon="✓" text="On-board Raspberry Pi CM 4" highlight />
342
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
343
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
344
  </>
@@ -372,25 +372,6 @@ function ProductCardsSection() {
372
  </Grid>
373
  ))}
374
  </Grid>
375
-
376
- {/* Lead time & shipping notice */}
377
- <Box sx={{ textAlign: 'center', mt: 5, mb: 6 }}>
378
- <Typography
379
- variant="body1"
380
- sx={{ fontWeight: 600, color: 'text.primary' }}
381
- >
382
- Current Lead time: 30 days for Lite, 60 days for Wireless after purchase
383
- </Typography>
384
- <Typography
385
- variant="body2"
386
- color="text.secondary"
387
- sx={{ maxWidth: 600, mx: 'auto', lineHeight: 1.7, mt: 1 }}
388
- >
389
- <strong>Import duties:</strong> EU/UK + US/Canada ship duty-paid (DDP).
390
- <br />
391
- Other destinations may incur local import duties/taxes on delivery (DAP).
392
- </Typography>
393
- </Box>
394
  </Container>
395
  );
396
  }
 
41
  price: 449,
42
  badge: 'Wireless',
43
  badgeColor: '#0ea5e9',
44
+ description: 'Self-contained robot with on-board compute. Works wirelessly or wired, perfect for standalone projects and demos. <strong>Ships in 90 days</strong>.',
45
  buyLink: 'https://buy.stripe.com/9B65kFfFlaKFbY34W873G03',
46
  image: '/assets/reachy-wireless.png',
47
  featured: true,
 
52
  price: 299,
53
  badge: 'Lite',
54
  badgeColor: '#f59e0b',
55
+ description: 'Connect to your computer via USB. Same expressive robot, powered by your machine. Ideal for development and learning. <strong>Ships in 90 days</strong>.',
56
  buyLink: 'https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02',
57
  image: '/assets/reachy-lite.png',
58
  featured: false,
 
68
  { name: 'Camera', wireless: 'Wide angle', lite: 'Wide angle' },
69
  { name: 'Microphones', wireless: '4 microphones array', lite: '4 microphones array' },
70
  { name: 'Speaker', wireless: '5W speaker', lite: '5W speaker' },
71
+ { name: 'On-board Compute', wireless: 'Raspberry Pi 4 (16GB storage)', lite: false },
72
  { name: 'Accelerometer', wireless: 'Built-in IMU', lite: false },
73
  { name: 'Wi-Fi Connectivity', wireless: 'Wi-Fi', lite: false },
74
  { name: 'Standalone Mode', wireless: true, lite: false },
 
90
  const faqItems = [
91
  {
92
  question: 'What is the difference between Wireless and Lite?',
93
+ answer: 'The Wireless version includes a Raspberry Pi 4 built-in, allowing it to run standalone without a computer. The Lite version connects to your Mac, Linux, or Windows computer via USB and uses your computer for processing. Both versions have the same mechanical design and audio/video capabilities.',
94
  },
95
  {
96
  question: 'How long does assembly take?',
 
98
  },
99
  {
100
  question: 'What about customs and import taxes?',
101
+ answer: 'The displayed price does not include customs duties or import taxes, which vary by country. You may be responsible for these fees upon delivery.',
102
  },
103
  {
104
  question: 'Can I upgrade from Lite to Wireless later?',
 
338
  <Stack spacing={1} sx={{ mb: 3 }}>
339
  {key === 'wireless' ? (
340
  <>
341
+ <FeatureRow icon="✓" text="On-board Raspberry Pi 4" highlight />
342
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
343
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
344
  </>
 
372
  </Grid>
373
  ))}
374
  </Grid>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  </Container>
376
  );
377
  }
src/pages/Download.jsx CHANGED
@@ -18,7 +18,6 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
18
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
19
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
20
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
21
- import DesktopWindowsIcon from '@mui/icons-material/DesktopWindows';
22
 
23
  import Layout from '../components/Layout';
24
 
@@ -30,6 +29,12 @@ const PLATFORMS = {
30
  arch: 'M1, M2, M3, M4',
31
  format: '.dmg',
32
  },
 
 
 
 
 
 
33
  'windows-x86_64': {
34
  name: 'Windows',
35
  subtitle: '64-bit',
@@ -66,11 +71,6 @@ function detectPlatform() {
66
  return 'darwin-aarch64';
67
  }
68
 
69
- function isMobileDevice() {
70
- const ua = navigator.userAgent;
71
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
72
- }
73
-
74
  // Format date
75
  function formatDate(dateString) {
76
  const date = new Date(dateString);
@@ -180,9 +180,6 @@ function parseReleasePlatforms(assets) {
180
  const name = asset.name.toLowerCase();
181
  const url = asset.browser_download_url;
182
 
183
- // Skip signature files entirely
184
- if (name.endsWith('.sig')) return;
185
-
186
  // macOS Apple Silicon - prefer .dmg
187
  if (name.includes('arm64.dmg')) {
188
  platforms['darwin-aarch64'] = { url };
@@ -190,13 +187,20 @@ function parseReleasePlatforms(assets) {
190
  platforms['darwin-aarch64'] = { url };
191
  }
192
 
193
- // Windows - .msi
 
 
 
 
 
 
 
194
  if (name.endsWith('.msi')) {
195
  platforms['windows-x86_64'] = { url };
196
  }
197
 
198
  // Linux - .deb
199
- if (name.endsWith('.deb')) {
200
  platforms['linux-x86_64'] = { url };
201
  }
202
  });
@@ -215,6 +219,7 @@ function getDownloadUrlForPlatform(release, platform) {
215
  // Platform Card component
216
  function PlatformCard({ platformKey, url, isActive, onClick }) {
217
  const platform = PLATFORMS[platformKey];
 
218
  const isBeta = platformKey.includes('windows') || platformKey.includes('linux');
219
 
220
  return (
@@ -230,17 +235,37 @@ function PlatformCard({ platformKey, url, isActive, onClick }) {
230
  borderColor: isActive ? 'rgba(59, 130, 246, 0.4)' : 'rgba(255, 255, 255, 0.08)',
231
  backdropFilter: 'blur(10px)',
232
  transition: 'all 0.25s ease',
 
233
  '&:hover': {
234
  borderColor: isActive ? 'rgba(59, 130, 246, 0.6)' : 'rgba(255, 255, 255, 0.2)',
235
  transform: 'translateY(-4px)',
236
  boxShadow: isActive
237
  ? '0 12px 40px rgba(59, 130, 246, 0.2)'
238
  : '0 12px 40px rgba(0, 0, 0, 0.3)',
 
239
  },
240
  }}
241
  >
242
- {/* Beta tag for Windows and Linux */}
243
- {isBeta && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  <Chip
245
  label="Beta"
246
  size="small"
@@ -260,7 +285,7 @@ function PlatformCard({ platformKey, url, isActive, onClick }) {
260
 
261
  <CardContent
262
  component="a"
263
- href={url}
264
  sx={{
265
  display: 'flex',
266
  flexDirection: 'column',
@@ -270,6 +295,7 @@ function PlatformCard({ platformKey, url, isActive, onClick }) {
270
  color: 'inherit',
271
  p: 3,
272
  '&:last-child': { pb: 3 },
 
273
  }}
274
  >
275
  <Box sx={{
@@ -321,13 +347,11 @@ export default function Download() {
321
  const [detectedPlatform, setDetectedPlatform] = useState(null);
322
  const [loading, setLoading] = useState(true);
323
  const [showAllReleases, setShowAllReleases] = useState(false);
324
- const [isMobile, setIsMobile] = useState(false);
325
 
326
  const [error, setError] = useState(null);
327
 
328
  useEffect(() => {
329
  setDetectedPlatform(detectPlatform());
330
- setIsMobile(isMobileDevice());
331
 
332
  // Fetch latest release info from GitHub API
333
  async function fetchReleases() {
@@ -532,34 +556,8 @@ export default function Download() {
532
  </Typography>
533
  </Stack>
534
 
535
- {/* Primary download button or mobile notice */}
536
- {isMobile ? (
537
- <Box
538
- sx={{
539
- mt: 2,
540
- p: 3,
541
- background: 'linear-gradient(135deg, rgba(255, 149, 0, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)',
542
- border: '1px solid rgba(255, 149, 0, 0.3)',
543
- borderRadius: 3,
544
- maxWidth: 500,
545
- mx: 'auto',
546
- }}
547
- >
548
- <DesktopWindowsIcon sx={{ fontSize: 40, color: 'rgba(255,255,255,0.5)', mb: 1.5 }} />
549
- <Typography
550
- variant="body1"
551
- sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 600, mb: 1 }}
552
- >
553
- Desktop only
554
- </Typography>
555
- <Typography
556
- variant="body2"
557
- sx={{ color: 'rgba(255,255,255,0.6)' }}
558
- >
559
- Reachy Mini Control is a desktop application available for macOS, Windows, and Linux. Please visit this page from a computer to download it.
560
- </Typography>
561
- </Box>
562
- ) : (
563
  <>
564
  <Button
565
  variant="contained"
@@ -616,13 +614,111 @@ export default function Download() {
616
  }}
617
  >
618
  {detectedPlatform?.startsWith('windows')
619
- ? <>⚠️ Windows version is currently in Beta - installation requires <strong style={{ color: 'rgba(255,255,255,0.9)' }}>administrator privileges</strong>.</>
620
- : <>⚠️ Linux version is currently in Beta - please report any issues on <a href="https://github.com/pollen-robotics/reachy-mini-desktop-app/issues" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>GitHub</a> or <a href="https://discord.gg/HDrGY9eJHt" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>Discord</a>.</>
621
  }
622
  </Typography>
623
  </Box>
624
  )}
625
  </>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  )}
627
 
628
  {/* App screenshot */}
@@ -641,37 +737,55 @@ export default function Download() {
641
  />
642
  </Box>
643
 
644
- {/* All platforms - hidden on mobile */}
645
- {!isMobile && (
646
- <Box sx={{ mb: 8 }}>
647
- <Typography
648
- variant="overline"
649
- sx={{
650
- color: 'rgba(255,255,255,0.4)',
651
- display: 'block',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  textAlign: 'center',
653
- mb: 3,
654
- letterSpacing: 2,
655
  }}
656
  >
657
- Available for all platforms
658
- </Typography>
659
-
660
- <Grid container spacing={2}>
661
- {['darwin-aarch64', 'windows-x86_64', 'linux-x86_64'].map((key) => (
662
- <Grid size={{ xs: 12, sm: 4 }} key={key}>
663
- <PlatformCard
664
- platformKey={key}
665
- url={releaseData?.platforms[key]?.url}
666
- isActive={key === detectedPlatform}
667
- onClick={() => setDetectedPlatform(key)}
668
- />
669
- </Grid>
670
- ))}
671
- </Grid>
672
-
673
- </Box>
674
- )}
675
 
676
  {/* Features / What's included */}
677
  <Box
 
18
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
19
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
20
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
 
21
 
22
  import Layout from '../components/Layout';
23
 
 
29
  arch: 'M1, M2, M3, M4',
30
  format: '.dmg',
31
  },
32
+ 'darwin-x86_64': {
33
+ name: 'macOS',
34
+ subtitle: 'Intel',
35
+ arch: 'x86_64',
36
+ format: '.dmg',
37
+ },
38
  'windows-x86_64': {
39
  name: 'Windows',
40
  subtitle: '64-bit',
 
71
  return 'darwin-aarch64';
72
  }
73
 
 
 
 
 
 
74
  // Format date
75
  function formatDate(dateString) {
76
  const date = new Date(dateString);
 
180
  const name = asset.name.toLowerCase();
181
  const url = asset.browser_download_url;
182
 
 
 
 
183
  // macOS Apple Silicon - prefer .dmg
184
  if (name.includes('arm64.dmg')) {
185
  platforms['darwin-aarch64'] = { url };
 
187
  platforms['darwin-aarch64'] = { url };
188
  }
189
 
190
+ // macOS Intel - prefer .dmg
191
+ if (name.includes('x64.dmg') && !name.includes('arm64')) {
192
+ platforms['darwin-x86_64'] = { url };
193
+ } else if (name.includes('darwin-x86_64') && !platforms['darwin-x86_64']) {
194
+ platforms['darwin-x86_64'] = { url };
195
+ }
196
+
197
+ // Windows - .msi (exclude .sig signature files)
198
  if (name.endsWith('.msi')) {
199
  platforms['windows-x86_64'] = { url };
200
  }
201
 
202
  // Linux - .deb
203
+ if (name.includes('amd64.deb')) {
204
  platforms['linux-x86_64'] = { url };
205
  }
206
  });
 
219
  // Platform Card component
220
  function PlatformCard({ platformKey, url, isActive, onClick }) {
221
  const platform = PLATFORMS[platformKey];
222
+ const isComingSoon = platformKey.includes('darwin-x86_64');
223
  const isBeta = platformKey.includes('windows') || platformKey.includes('linux');
224
 
225
  return (
 
235
  borderColor: isActive ? 'rgba(59, 130, 246, 0.4)' : 'rgba(255, 255, 255, 0.08)',
236
  backdropFilter: 'blur(10px)',
237
  transition: 'all 0.25s ease',
238
+ opacity: isComingSoon ? 0.7 : 1,
239
  '&:hover': {
240
  borderColor: isActive ? 'rgba(59, 130, 246, 0.6)' : 'rgba(255, 255, 255, 0.2)',
241
  transform: 'translateY(-4px)',
242
  boxShadow: isActive
243
  ? '0 12px 40px rgba(59, 130, 246, 0.2)'
244
  : '0 12px 40px rgba(0, 0, 0, 0.3)',
245
+ opacity: 1,
246
  },
247
  }}
248
  >
249
+ {/* Coming soon tag */}
250
+ {isComingSoon && (
251
+ <Chip
252
+ label="Coming soon"
253
+ size="small"
254
+ sx={{
255
+ position: 'absolute',
256
+ top: 8,
257
+ right: 8,
258
+ backgroundColor: 'rgba(255, 149, 0, 0.2)',
259
+ color: '#FF9500',
260
+ fontSize: 10,
261
+ fontWeight: 700,
262
+ height: 20,
263
+ '& .MuiChip-label': { px: 1 },
264
+ }}
265
+ />
266
+ )}
267
+ {/* Beta tag for Windows */}
268
+ {isBeta && !isComingSoon && (
269
  <Chip
270
  label="Beta"
271
  size="small"
 
285
 
286
  <CardContent
287
  component="a"
288
+ href={isComingSoon ? undefined : url}
289
  sx={{
290
  display: 'flex',
291
  flexDirection: 'column',
 
295
  color: 'inherit',
296
  p: 3,
297
  '&:last-child': { pb: 3 },
298
+ pointerEvents: isComingSoon ? 'none' : 'auto',
299
  }}
300
  >
301
  <Box sx={{
 
347
  const [detectedPlatform, setDetectedPlatform] = useState(null);
348
  const [loading, setLoading] = useState(true);
349
  const [showAllReleases, setShowAllReleases] = useState(false);
 
350
 
351
  const [error, setError] = useState(null);
352
 
353
  useEffect(() => {
354
  setDetectedPlatform(detectPlatform());
 
355
 
356
  // Fetch latest release info from GitHub API
357
  async function fetchReleases() {
 
556
  </Typography>
557
  </Stack>
558
 
559
+ {/* Primary download button - different for Windows/macOS Apple Silicon/Linux vs macOS Intel */}
560
+ {detectedPlatform?.startsWith('windows') || detectedPlatform === 'darwin-aarch64' || detectedPlatform?.includes('linux') ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  <>
562
  <Button
563
  variant="contained"
 
614
  }}
615
  >
616
  {detectedPlatform?.startsWith('windows')
617
+ ? <>⚠️ Windows version is currently in Beta installation requires <strong style={{ color: 'rgba(255,255,255,0.9)' }}>administrator privileges</strong>.</>
618
+ : <>⚠️ Linux version is currently in Beta please report any issues on <a href="https://github.com/pollen-robotics/reachy-mini-desktop-app/issues" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>GitHub</a> or <a href="https://discord.gg/HDrGY9eJHt" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>Discord</a>.</>
619
  }
620
  </Typography>
621
  </Box>
622
  )}
623
  </>
624
+ ) : (
625
+ <>
626
+ {/* macOS Intel - Coming soon message */}
627
+ <Box
628
+ sx={{
629
+ px: 5,
630
+ py: 2.5,
631
+ borderRadius: 3,
632
+ background: 'linear-gradient(135deg, rgba(255, 149, 0, 0.15) 0%, rgba(139, 92, 246, 0.1) 100%)',
633
+ border: '1px solid rgba(255, 149, 0, 0.3)',
634
+ display: 'inline-block',
635
+ }}
636
+ >
637
+ <Typography
638
+ variant="h6"
639
+ sx={{
640
+ color: '#FF9500',
641
+ fontWeight: 600,
642
+ fontSize: 17,
643
+ }}
644
+ >
645
+ 🚧 {currentPlatform?.name} desktop app coming soon!
646
+ </Typography>
647
+ </Box>
648
+
649
+ <Typography
650
+ variant="body2"
651
+ sx={{
652
+ color: 'rgba(255,255,255,0.5)',
653
+ mt: 2,
654
+ fontSize: 14,
655
+ maxWidth: 500,
656
+ mx: 'auto',
657
+ }}
658
+ >
659
+ We're working hard to bring Reachy Mini Control to {currentPlatform?.name}.
660
+ In the meantime, macOS (Apple Silicon) is fully supported, and Windows & Linux are in beta!
661
+ </Typography>
662
+
663
+ {/* Alternative for Linux/advanced users - Python SDK */}
664
+ <Box
665
+ sx={{
666
+ mt: 3,
667
+ p: 3,
668
+ borderRadius: 2,
669
+ background: 'rgba(16, 185, 129, 0.08)',
670
+ border: '1px solid rgba(16, 185, 129, 0.25)',
671
+ maxWidth: 520,
672
+ mx: 'auto',
673
+ textAlign: 'left',
674
+ }}
675
+ >
676
+ <Typography
677
+ variant="body2"
678
+ sx={{
679
+ color: 'rgba(255,255,255,0.9)',
680
+ fontWeight: 600,
681
+ mb: 1,
682
+ fontSize: 14,
683
+ }}
684
+ >
685
+ 🐍 Looking to use the Python SDK directly?
686
+ </Typography>
687
+ <Typography
688
+ variant="body2"
689
+ sx={{
690
+ color: 'rgba(255,255,255,0.6)',
691
+ fontSize: 13,
692
+ lineHeight: 1.6,
693
+ mb: 1.5,
694
+ }}
695
+ >
696
+ {detectedPlatform?.includes('linux')
697
+ ? "Linux users can interact directly with their Reachy Mini using the Python SDK. Run the daemon locally and use the full API for custom applications."
698
+ : "Advanced users can interact directly with their Reachy Mini using the Python SDK and daemon."}
699
+ </Typography>
700
+ <Button
701
+ variant="outlined"
702
+ size="small"
703
+ href="https://huggingface.co/docs/reachy_mini/"
704
+ target="_blank"
705
+ endIcon={<OpenInNewIcon sx={{ fontSize: 14 }} />}
706
+ sx={{
707
+ color: '#10b981',
708
+ borderColor: 'rgba(16, 185, 129, 0.4)',
709
+ fontWeight: 600,
710
+ fontSize: 12,
711
+ textTransform: 'none',
712
+ '&:hover': {
713
+ borderColor: '#10b981',
714
+ bgcolor: 'rgba(16, 185, 129, 0.1)',
715
+ },
716
+ }}
717
+ >
718
+ View reachy_mini on GitHub
719
+ </Button>
720
+ </Box>
721
+ </>
722
  )}
723
 
724
  {/* App screenshot */}
 
737
  />
738
  </Box>
739
 
740
+ {/* All platforms */}
741
+ <Box sx={{ mb: 8 }}>
742
+ <Typography
743
+ variant="overline"
744
+ sx={{
745
+ color: 'rgba(255,255,255,0.4)',
746
+ display: 'block',
747
+ textAlign: 'center',
748
+ mb: 3,
749
+ letterSpacing: 2,
750
+ }}
751
+ >
752
+ Available for all platforms
753
+ </Typography>
754
+
755
+ <Grid container spacing={2}>
756
+ {['darwin-aarch64', 'darwin-x86_64', 'windows-x86_64', 'linux-x86_64'].map((key) => (
757
+ <Grid size={{ xs: 6, sm: 3 }} key={key}>
758
+ <PlatformCard
759
+ platformKey={key}
760
+ url={releaseData?.platforms[key]?.url}
761
+ isActive={key === detectedPlatform}
762
+ onClick={() => setDetectedPlatform(key)}
763
+ />
764
+ </Grid>
765
+ ))}
766
+ </Grid>
767
+
768
+ {/* Platform support notice - show on Windows, macOS Apple Silicon, and Linux */}
769
+ {(detectedPlatform?.startsWith('windows') || detectedPlatform === 'darwin-aarch64' || detectedPlatform?.includes('linux')) && (
770
+ <Box
771
+ sx={{
772
+ mt: 3,
773
+ p: 2,
774
+ background: 'rgba(255, 255, 255, 0.03)',
775
+ border: '1px solid rgba(255, 255, 255, 0.08)',
776
+ borderRadius: 2,
777
  textAlign: 'center',
 
 
778
  }}
779
  >
780
+ <Typography
781
+ variant="body2"
782
+ sx={{ color: 'rgba(255,255,255,0.5)' }}
783
+ >
784
+ 🚧 macOS Intel support coming soon
785
+ </Typography>
786
+ </Box>
787
+ )}
788
+ </Box>
 
 
 
 
 
 
 
 
 
789
 
790
  {/* Features / What's included */}
791
  <Box
src/pages/GettingStarted.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
2
  import { Link as RouterLink, useLocation } from 'react-router-dom';
3
  import {
4
  Box,
@@ -18,7 +18,6 @@ import {
18
  } from '@mui/material';
19
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
20
  import DownloadIcon from '@mui/icons-material/Download';
21
- import DesktopWindowsIcon from '@mui/icons-material/DesktopWindows';
22
  import WifiIcon from '@mui/icons-material/Wifi';
23
  import UsbIcon from '@mui/icons-material/Usb';
24
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@@ -142,11 +141,6 @@ function YouTubeEmbed({ videoId, title, version = 'wireless' }) {
142
  );
143
  }
144
 
145
- function isMobileDevice() {
146
- const ua = navigator.userAgent;
147
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
148
- }
149
-
150
  export default function GettingStarted() {
151
  const location = useLocation();
152
  const params = new URLSearchParams(location.search);
@@ -154,11 +148,6 @@ export default function GettingStarted() {
154
  const [version, setVersion] = useState(
155
  urlVersion === 'lite' ? 'lite' : 'wireless'
156
  );
157
- const [isMobile, setIsMobile] = useState(false);
158
-
159
- useEffect(() => {
160
- setIsMobile(isMobileDevice());
161
- }, []);
162
 
163
  return (
164
  <Layout transparentHeader>
@@ -337,47 +326,25 @@ export default function GettingStarted() {
337
  The desktop app includes everything you need to control your Lite version.
338
  </Typography>
339
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
340
- Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
341
  </Typography>
342
- {isMobile ? (
343
- <Box
344
- sx={{
345
- p: 2,
346
- bgcolor: 'action.hover',
347
- borderRadius: 2,
348
- border: '1px solid',
349
- borderColor: 'divider',
350
- display: 'flex',
351
- alignItems: 'center',
352
- gap: 1.5,
353
- }}
354
- >
355
- <DesktopWindowsIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
356
- <Typography variant="body2" color="text.secondary">
357
- The desktop app can only be downloaded from a computer.
358
- </Typography>
359
- </Box>
360
- ) : (
361
- <>
362
- <Button
363
- variant="contained"
364
- component={RouterLink}
365
- to="/download"
366
- startIcon={<DownloadIcon/>}
367
- >
368
- Download Desktop App
369
- </Button>
370
-
371
- <Button
372
- variant="outlined"
373
- href="https://huggingface.co/docs/reachy_mini/SDK/installation"
374
- target="_blank"
375
- startIcon={<OpenInNewIcon/>}
376
- >
377
- Alternative: Python SDK
378
- </Button>
379
- </>
380
- )}
381
 
382
  </StepContent>
383
  </Step>
@@ -433,7 +400,7 @@ export default function GettingStarted() {
433
 
434
  <Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
435
  Follow our visual guide to put together your Reachy Mini.
436
- Most people finish in <strong>2-3 hours</strong> - our record is 43 minutes! 🏆
437
  </Typography>
438
 
439
  <Box
@@ -471,7 +438,7 @@ export default function GettingStarted() {
471
  </Typography>
472
  </Box>
473
 
474
- {/* Step 2: Connect & Install */}
475
  <Box sx={{ mb: 10 }}>
476
  <Box sx={{ mb: 3 }}>
477
  <Typography
@@ -485,107 +452,95 @@ export default function GettingStarted() {
485
  >
486
  Step 2
487
  </Typography>
488
- <Typography variant="h3" sx={{ mt: 0.5 }}>Connect & install the app</Typography>
489
  </Box>
490
 
491
- <Grid container spacing={4} alignItems="center">
492
- <Grid size={{ xs: 12, md: 6 }}>
493
- <Stepper orientation="vertical">
494
- <Step active completed={false}>
495
- <StepLabel>
496
- <Typography fontWeight={600}>Power on your Reachy Mini</Typography>
497
- </StepLabel>
498
- <StepContent>
499
- <Typography variant="body2" color="text.secondary">
500
- Wait about 30 seconds for the robot to boot up.
501
- </Typography>
502
- </StepContent>
503
- </Step>
504
- <Step active completed={false}>
505
- <StepLabel>
506
- <Typography fontWeight={600}>Download the desktop app</Typography>
507
- </StepLabel>
508
- <StepContent>
509
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
510
- The desktop app includes everything you need to control your Wireless version.
511
- </Typography>
512
- <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
513
- Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
514
- </Typography>
515
- {isMobile ? (
516
- <Box
517
- sx={{
518
- p: 2,
519
- bgcolor: 'action.hover',
520
- borderRadius: 2,
521
- border: '1px solid',
522
- borderColor: 'divider',
523
- display: 'flex',
524
- alignItems: 'center',
525
- gap: 1.5,
526
- }}
527
- >
528
- <DesktopWindowsIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
529
- <Typography variant="body2" color="text.secondary">
530
- The desktop app can only be downloaded from a computer.
531
- </Typography>
532
- </Box>
533
- ) : (
534
- <>
535
- <Button
536
- variant="contained"
537
- component={RouterLink}
538
- to="/download"
539
- startIcon={<DownloadIcon/>}
540
- >
541
- Download Desktop App
542
- </Button>
543
-
544
- <Button
545
- variant="outlined"
546
- href="https://huggingface.co/docs/reachy_mini/SDK/installation"
547
- target="_blank"
548
- startIcon={<OpenInNewIcon/>}
549
- >
550
- Alternative: Python SDK
551
- </Button>
552
- </>
553
- )}
554
- </StepContent>
555
- </Step>
556
- <Step active completed={false}>
557
- <StepLabel>
558
- <Typography fontWeight={600}>Connect the robot to your Wi-Fi</Typography>
559
- </StepLabel>
560
- <StepContent>
561
- <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
562
- Use the desktop app to connect your robot to your Wi-Fi network.
563
- </Typography>
564
- </StepContent>
565
- </Step>
566
- <Step active completed={false}>
567
- <StepLabel>
568
- <Typography fontWeight={600}>Open the app & play!</Typography>
569
- </StepLabel>
570
- <StepContent>
571
- <Typography variant="body2" color="text.secondary">
572
- The app will automatically detect your robot. Click "Wake Up" to start,
573
- then explore 30+ apps created by the community, from hand tracking to AI conversations! If you experience any issues, feel free to check our <a href="https://huggingface.co/docs/reachy_mini/troubleshooting" target="_blank">Troubleshooting & FAQ page</a> or contact us on <a href="https://discord.gg/HDrGY9eJHt" target="_blank">Discord</a>.
574
- </Typography>
575
- </StepContent>
576
- </Step>
577
- </Stepper>
578
- </Grid>
579
-
580
- <Grid size={{ xs: 12, md: 6 }}>
581
- <Box
582
- component="img"
583
- src="/assets/desktop-app-screenshot--dark.png"
584
- alt="Reachy Mini Control App"
585
- sx={{ width: '100%', display: 'block', borderRadius: '12px' }}
586
- />
587
- </Grid>
588
- </Grid>
589
  </Box>
590
  </>
591
  )}
 
1
+ import { useState } from 'react';
2
  import { Link as RouterLink, useLocation } from 'react-router-dom';
3
  import {
4
  Box,
 
18
  } from '@mui/material';
19
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
20
  import DownloadIcon from '@mui/icons-material/Download';
 
21
  import WifiIcon from '@mui/icons-material/Wifi';
22
  import UsbIcon from '@mui/icons-material/Usb';
23
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
 
141
  );
142
  }
143
 
 
 
 
 
 
144
  export default function GettingStarted() {
145
  const location = useLocation();
146
  const params = new URLSearchParams(location.search);
 
148
  const [version, setVersion] = useState(
149
  urlVersion === 'lite' ? 'lite' : 'wireless'
150
  );
 
 
 
 
 
151
 
152
  return (
153
  <Layout transparentHeader>
 
326
  The desktop app includes everything you need to control your Lite version.
327
  </Typography>
328
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
329
+ Desktop App available for macOS (Apple Silicon), Windows & Linux (beta). macOS Intel coming soon!
330
  </Typography>
331
+ <Button
332
+ variant="contained"
333
+ component={RouterLink}
334
+ to="/download"
335
+ startIcon={<DownloadIcon/>}
336
+ >
337
+ Download Desktop App
338
+ </Button>
339
+
340
+ <Button
341
+ variant="outlined"
342
+ href="https://huggingface.co/docs/reachy_mini/SDK/installation"
343
+ target="_blank"
344
+ startIcon={<OpenInNewIcon/>}
345
+ >
346
+ Alternative: Python SDK
347
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
 
349
  </StepContent>
350
  </Step>
 
400
 
401
  <Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
402
  Follow our visual guide to put together your Reachy Mini.
403
+ Most people finish in <strong>2-3 hours</strong> our record is 43 minutes! 🏆
404
  </Typography>
405
 
406
  <Box
 
438
  </Typography>
439
  </Box>
440
 
441
+ {/* Step 2: Connect to Wi-Fi */}
442
  <Box sx={{ mb: 10 }}>
443
  <Box sx={{ mb: 3 }}>
444
  <Typography
 
452
  >
453
  Step 2
454
  </Typography>
455
+ <Typography variant="h3" sx={{ mt: 0.5 }}>Connect to your Wi-Fi</Typography>
456
  </Box>
457
 
458
+ <Stepper orientation="vertical">
459
+ <Step active completed={false}>
460
+ <StepLabel>
461
+ <Typography fontWeight={600}>Power on your Reachy Mini</Typography>
462
+ </StepLabel>
463
+ <StepContent>
464
+ <Typography variant="body2" color="text.secondary">
465
+ Wait about 30 seconds for the robot to boot up.
466
+ </Typography>
467
+ </StepContent>
468
+ </Step>
469
+ <Step active completed={false}>
470
+ <StepLabel>
471
+ <Typography fontWeight={600}>Connect to the robot's Wi-Fi</Typography>
472
+ </StepLabel>
473
+ <StepContent>
474
+ <Grid container columnSpacing={2.5} rowSpacing={2} alignItems="center">
475
+ <Grid
476
+ size={{ xs: 12, md: 6 }}
477
+ sx={{
478
+ flexBasis: { md: '110px', xs: '100%' },
479
+ maxWidth: { md: '110px', xs: '100%' },
480
+ display: 'flex',
481
+ alignItems: 'center',
482
+ }}
483
+ >
484
+ <Box
485
+ component="img"
486
+ src="/assets/reachy-mini-access-point-QR-code.png"
487
+ alt="Reachy Mini access point QR code"
488
+ sx={{ height: 100, width: 'auto', display: 'block' }}
489
+ />
490
+ </Grid>
491
+ <Grid size={{ xs: 12, md: 6 }} sx={{ display: 'flex', alignItems: 'center' }}>
492
+ <Box sx={{ bgcolor: 'background.alt', p: 2, borderRadius: 2, mb: 1 }}>
493
+ <Typography variant="body2">
494
+ <strong>Network:</strong> <code>reachy-mini-ap</code>
495
+ </Typography>
496
+ <Typography variant="body2">
497
+ <strong>Password:</strong> <code>reachy-mini</code>
498
+ </Typography>
499
+ </Box>
500
+ </Grid>
501
+ </Grid>
502
+ </StepContent>
503
+ </Step>
504
+ <Step active completed={false}>
505
+ <StepLabel>
506
+ <Typography fontWeight={600}>Open settings in your browser</Typography>
507
+ </StepLabel>
508
+ <StepContent>
509
+ <Button
510
+ variant="outlined"
511
+ size="small"
512
+ href="http://reachy-mini.local:8000/settings"
513
+ target="_blank"
514
+ endIcon={<OpenInNewIcon />}
515
+ >
516
+ http://reachy-mini.local:8000/settings
517
+ </Button>
518
+ </StepContent>
519
+ </Step>
520
+ <Step active completed={false}>
521
+ <StepLabel>
522
+ <Typography fontWeight={600}>Enter your Wi-Fi & update</Typography>
523
+ </StepLabel>
524
+ <StepContent>
525
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
526
+ The robot will restart and connect to your network. Then access the dashboard
527
+ to explore 30+ apps, from hand tracking to AI conversations!
528
+ </Typography>
529
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 2, fontStyle: 'italic' }}>
530
+ If the dashboard doesn't appear correctly, please use Chrome or Firefox.
531
+ </Typography>
532
+ <Button
533
+ variant="contained"
534
+ size="small"
535
+ href="http://reachy-mini.local:8000"
536
+ target="_blank"
537
+ endIcon={<OpenInNewIcon />}
538
+ >
539
+ Open Dashboard
540
+ </Button>
541
+ </StepContent>
542
+ </Step>
543
+ </Stepper>
 
 
 
 
 
 
 
 
 
 
 
 
544
  </Box>
545
  </>
546
  )}
src/pages/Home.jsx CHANGED
@@ -665,7 +665,7 @@ function ProductsSection() {
665
  sx={{ mb: 4, textAlign: "left", maxWidth: 280, mx: "auto" }}
666
  >
667
  {[
668
- "Raspberry Pi CM 4 on-board",
669
  "Wi-Fi + USB",
670
  "Camera, 4 mics, speaker",
671
  "Accelerometer",
@@ -763,21 +763,7 @@ function ProductsSection() {
763
  fontWeight: 600,
764
  }}
765
  >
766
- Current Lead time: 30 days for Lite, 60 days for Wireless after purchase
767
- </Typography>
768
- <Typography
769
- variant="body2"
770
- sx={{
771
- color: "text.secondary",
772
- mt: 1,
773
- maxWidth: 520,
774
- mx: "auto",
775
- lineHeight: 1.7,
776
- }}
777
- >
778
- <strong>Import duties:</strong> EU/UK + US/Canada ship duty-paid (DDP).
779
- <br />
780
- Other destinations may incur local import duties/taxes on delivery (DAP).
781
  </Typography>
782
  </Box>
783
 
 
665
  sx={{ mb: 4, textAlign: "left", maxWidth: 280, mx: "auto" }}
666
  >
667
  {[
668
+ "Raspberry Pi 4 on-board",
669
  "Wi-Fi + USB",
670
  "Camera, 4 mics, speaker",
671
  "Accelerometer",
 
763
  fontWeight: 600,
764
  }}
765
  >
766
+ Current Lead time: 90 days after purchase
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
  </Typography>
768
  </Box>
769
 
src/workers/searchWorker.js DELETED
@@ -1,71 +0,0 @@
1
- import Fuse from 'fuse.js';
2
-
3
- let fuse = null;
4
-
5
- const FUSE_OPTIONS = {
6
- keys: [
7
- { name: 'name', weight: 3 },
8
- { name: 'id', weight: 2 },
9
- { name: '_searchAuthor', weight: 2 },
10
- { name: '_searchDescription', weight: 1.5 },
11
- { name: '_searchTags', weight: 1 },
12
- ],
13
- threshold: 0.35,
14
- ignoreLocation: true,
15
- includeScore: true,
16
- includeMatches: true,
17
- };
18
-
19
- /**
20
- * Build a Fuse.js index from app data.
21
- * Flattens searchable fields for better matching.
22
- */
23
- function buildIndex(apps) {
24
- const enriched = apps.map((app) => ({
25
- id: app.id,
26
- name: app.name,
27
- _searchAuthor: app.extra?.author || app.id?.split('/')?.[0] || '',
28
- _searchDescription:
29
- app.extra?.cardData?.short_description || app.description || '',
30
- _searchTags: [...(app.extra?.tags || []), ...(app.extra?.cardData?.tags || [])]
31
- .filter(Boolean)
32
- .join(' '),
33
- }));
34
-
35
- fuse = new Fuse(enriched, FUSE_OPTIONS);
36
- }
37
-
38
- /**
39
- * Search and return ordered list of matching app IDs with scores.
40
- */
41
- /**
42
- * Search and return ordered list of matching app IDs with scores and match indices.
43
- * Matches are keyed by field name for easy highlight rendering.
44
- */
45
- function search(query) {
46
- if (!fuse || !query.trim()) return [];
47
- const results = fuse.search(query.trim());
48
- return results.map((r) => {
49
- // Convert Fuse matches array into a map: fieldName → [[start, end], ...]
50
- const matches = {};
51
- if (r.matches) {
52
- for (const m of r.matches) {
53
- matches[m.key] = m.indices;
54
- }
55
- }
56
- return { id: r.item.id, score: r.score, matches };
57
- });
58
- }
59
-
60
- // Handle messages from the main thread
61
- self.onmessage = (e) => {
62
- const { type, apps, query } = e.data;
63
-
64
- if (type === 'INDEX') {
65
- buildIndex(apps);
66
- self.postMessage({ type: 'INDEXED' });
67
- } else if (type === 'SEARCH') {
68
- const results = search(query);
69
- self.postMessage({ type: 'RESULTS', results, query });
70
- }
71
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
yarn.lock CHANGED
@@ -470,20 +470,6 @@
470
  "@eslint/core" "^0.17.0"
471
  levn "^0.4.1"
472
 
473
- "@huggingface/hub@^2.8.1":
474
- version "2.8.1"
475
- resolved "https://registry.npmjs.org/@huggingface/hub/-/hub-2.8.1.tgz"
476
- integrity sha512-VAsXdMiIHPteXQJhrwaBEiePTWiJ0zBSymHdnX4J+AijjNN0h3RzGfkKemXcu75gu/TmRLFY9l8+2Tkdmpis0w==
477
- dependencies:
478
- "@huggingface/tasks" "^0.19.82"
479
- optionalDependencies:
480
- cli-progress "^3.12.0"
481
-
482
- "@huggingface/tasks@^0.19.82":
483
- version "0.19.83"
484
- resolved "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.19.83.tgz"
485
- integrity sha512-nBt3S6x+MWUTmfey1drQZRMuEopEbz2aEMUsoddfpCuzIYAMCsJDX7xeNuJnzvbVGis3gXXCRcLHVhFtHaaiyA==
486
-
487
  "@humanfs/core@^0.19.1":
488
  version "0.19.1"
489
  resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"
@@ -922,7 +908,7 @@
922
 
923
  accepts@~1.3.8:
924
  version "1.3.8"
925
- resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz"
926
  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
927
  dependencies:
928
  mime-types "~2.1.34"
@@ -948,11 +934,6 @@ ajv@^6.12.4:
948
  json-schema-traverse "^0.4.1"
949
  uri-js "^4.2.2"
950
 
951
- ansi-regex@^5.0.1:
952
- version "5.0.1"
953
- resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz"
954
- integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
955
-
956
  ansi-styles@^4.1.0:
957
  version "4.3.0"
958
  resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
@@ -967,7 +948,7 @@ argparse@^2.0.1:
967
 
968
  array-flatten@1.1.1:
969
  version "1.1.1"
970
- resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz"
971
  integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
972
 
973
  babel-plugin-macros@^3.1.0:
@@ -996,7 +977,7 @@ baseline-browser-mapping@^2.9.0:
996
 
997
  body-parser@~1.20.3:
998
  version "1.20.4"
999
- resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz"
1000
  integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==
1001
  dependencies:
1002
  bytes "~3.1.2"
@@ -1033,12 +1014,12 @@ browserslist@^4.24.0:
1033
 
1034
  bytes@~3.1.2:
1035
  version "3.1.2"
1036
- resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
1037
  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
1038
 
1039
  call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
1040
  version "1.0.2"
1041
- resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz"
1042
  integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
1043
  dependencies:
1044
  es-errors "^1.3.0"
@@ -1046,7 +1027,7 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
1046
 
1047
  call-bound@^1.0.2:
1048
  version "1.0.4"
1049
- resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz"
1050
  integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
1051
  dependencies:
1052
  call-bind-apply-helpers "^1.0.2"
@@ -1095,13 +1076,6 @@ character-reference-invalid@^2.0.0:
1095
  resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz"
1096
  integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
1097
 
1098
- cli-progress@^3.12.0:
1099
- version "3.12.0"
1100
- resolved "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz"
1101
- integrity sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==
1102
- dependencies:
1103
- string-width "^4.2.3"
1104
-
1105
  clsx@^2.1.1:
1106
  version "2.1.1"
1107
  resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
@@ -1131,14 +1105,14 @@ concat-map@0.0.1:
1131
 
1132
  content-disposition@~0.5.4:
1133
  version "0.5.4"
1134
- resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
1135
  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
1136
  dependencies:
1137
  safe-buffer "5.2.1"
1138
 
1139
  content-type@~1.0.4, content-type@~1.0.5:
1140
  version "1.0.5"
1141
- resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz"
1142
  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
1143
 
1144
  convert-source-map@^1.5.0:
@@ -1153,7 +1127,7 @@ convert-source-map@^2.0.0:
1153
 
1154
  cookie-signature@~1.0.6:
1155
  version "1.0.7"
1156
- resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz"
1157
  integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==
1158
 
1159
  cookie@^1.0.1:
@@ -1163,7 +1137,7 @@ cookie@^1.0.1:
1163
 
1164
  cookie@~0.7.1:
1165
  version "0.7.2"
1166
- resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz"
1167
  integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
1168
 
1169
  cosmiconfig@^7.0.0:
@@ -1193,7 +1167,7 @@ csstype@^3.0.2, csstype@^3.1.3, csstype@^3.2.2:
1193
 
1194
  debug@2.6.9:
1195
  version "2.6.9"
1196
- resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
1197
  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
1198
  dependencies:
1199
  ms "2.0.0"
@@ -1219,7 +1193,7 @@ deep-is@^0.1.3:
1219
 
1220
  depd@2.0.0, depd@~2.0.0:
1221
  version "2.0.0"
1222
- resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz"
1223
  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
1224
 
1225
  dequal@^2.0.0:
@@ -1229,7 +1203,7 @@ dequal@^2.0.0:
1229
 
1230
  destroy@1.2.0, destroy@~1.2.0:
1231
  version "1.2.0"
1232
- resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz"
1233
  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
1234
 
1235
  devlop@^1.0.0, devlop@^1.1.0:
@@ -1249,7 +1223,7 @@ dom-helpers@^5.0.1:
1249
 
1250
  dunder-proto@^1.0.1:
1251
  version "1.0.1"
1252
- resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"
1253
  integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
1254
  dependencies:
1255
  call-bind-apply-helpers "^1.0.1"
@@ -1258,7 +1232,7 @@ dunder-proto@^1.0.1:
1258
 
1259
  ee-first@1.1.1:
1260
  version "1.1.1"
1261
- resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
1262
  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
1263
 
1264
  electron-to-chromium@^1.5.263:
@@ -1266,14 +1240,9 @@ electron-to-chromium@^1.5.263:
1266
  resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz"
1267
  integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
1268
 
1269
- emoji-regex@^8.0.0:
1270
- version "8.0.0"
1271
- resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
1272
- integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
1273
-
1274
  encodeurl@~2.0.0:
1275
  version "2.0.0"
1276
- resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
1277
  integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
1278
 
1279
  error-ex@^1.3.1:
@@ -1285,17 +1254,17 @@ error-ex@^1.3.1:
1285
 
1286
  es-define-property@^1.0.1:
1287
  version "1.0.1"
1288
- resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz"
1289
  integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
1290
 
1291
  es-errors@^1.3.0:
1292
  version "1.3.0"
1293
- resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz"
1294
  integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
1295
 
1296
  es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
1297
  version "1.1.1"
1298
- resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz"
1299
  integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
1300
  dependencies:
1301
  es-errors "^1.3.0"
@@ -1339,7 +1308,7 @@ escalade@^3.2.0:
1339
 
1340
  escape-html@~1.0.3:
1341
  version "1.0.3"
1342
- resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
1343
  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
1344
 
1345
  escape-string-regexp@^4.0.0:
@@ -1466,12 +1435,12 @@ esutils@^2.0.2:
1466
 
1467
  etag@~1.8.1:
1468
  version "1.8.1"
1469
- resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"
1470
  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
1471
 
1472
  express@^4.21.2:
1473
  version "4.22.1"
1474
- resolved "https://registry.npmjs.org/express/-/express-4.22.1.tgz"
1475
  integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==
1476
  dependencies:
1477
  accepts "~1.3.8"
@@ -1540,7 +1509,7 @@ file-entry-cache@^8.0.0:
1540
 
1541
  finalhandler@~1.3.1:
1542
  version "1.3.2"
1543
- resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz"
1544
  integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==
1545
  dependencies:
1546
  debug "2.6.9"
@@ -1579,7 +1548,7 @@ flatted@^3.2.9:
1579
 
1580
  forwarded@0.2.0:
1581
  version "0.2.0"
1582
- resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
1583
  integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
1584
 
1585
  framer-motion@^12.23.26:
@@ -1593,7 +1562,7 @@ framer-motion@^12.23.26:
1593
 
1594
  fresh@~0.5.2:
1595
  version "0.5.2"
1596
- resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz"
1597
  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
1598
 
1599
  fsevents@~2.3.2, fsevents@~2.3.3:
@@ -1606,11 +1575,6 @@ function-bind@^1.1.2:
1606
  resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
1607
  integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
1608
 
1609
- fuse.js@^7.1.0:
1610
- version "7.1.0"
1611
- resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.1.0.tgz#306228b4befeee11e05b027087c2744158527d09"
1612
- integrity sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==
1613
-
1614
  gensync@^1.0.0-beta.2:
1615
  version "1.0.0-beta.2"
1616
  resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
@@ -1618,7 +1582,7 @@ gensync@^1.0.0-beta.2:
1618
 
1619
  get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
1620
  version "1.3.0"
1621
- resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
1622
  integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
1623
  dependencies:
1624
  call-bind-apply-helpers "^1.0.2"
@@ -1634,7 +1598,7 @@ get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
1634
 
1635
  get-proto@^1.0.1:
1636
  version "1.0.1"
1637
- resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz"
1638
  integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
1639
  dependencies:
1640
  dunder-proto "^1.0.1"
@@ -1659,7 +1623,7 @@ globals@^16.5.0:
1659
 
1660
  gopd@^1.2.0:
1661
  version "1.2.0"
1662
- resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
1663
  integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
1664
 
1665
  has-flag@^4.0.0:
@@ -1669,7 +1633,7 @@ has-flag@^4.0.0:
1669
 
1670
  has-symbols@^1.1.0:
1671
  version "1.1.0"
1672
- resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz"
1673
  integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
1674
 
1675
  hasown@^2.0.2:
@@ -1755,7 +1719,7 @@ html-url-attributes@^3.0.0:
1755
 
1756
  http-errors@~2.0.0, http-errors@~2.0.1:
1757
  version "2.0.1"
1758
- resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz"
1759
  integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
1760
  dependencies:
1761
  depd "~2.0.0"
@@ -1766,7 +1730,7 @@ http-errors@~2.0.0, http-errors@~2.0.1:
1766
 
1767
  iconv-lite@~0.4.24:
1768
  version "0.4.24"
1769
- resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz"
1770
  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
1771
  dependencies:
1772
  safer-buffer ">= 2.1.2 < 3"
@@ -1791,7 +1755,7 @@ imurmurhash@^0.1.4:
1791
 
1792
  inherits@~2.0.4:
1793
  version "2.0.4"
1794
- resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
1795
  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
1796
 
1797
  inline-style-parser@0.2.7:
@@ -1801,7 +1765,7 @@ inline-style-parser@0.2.7:
1801
 
1802
  ipaddr.js@1.9.1:
1803
  version "1.9.1"
1804
- resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
1805
  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
1806
 
1807
  is-alphabetical@^2.0.0:
@@ -1839,11 +1803,6 @@ is-extglob@^2.1.1:
1839
  resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
1840
  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
1841
 
1842
- is-fullwidth-code-point@^3.0.0:
1843
- version "3.0.0"
1844
- resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz"
1845
- integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
1846
-
1847
  is-glob@^4.0.0, is-glob@^4.0.3:
1848
  version "4.0.3"
1849
  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
@@ -1975,7 +1934,7 @@ markdown-table@^3.0.0:
1975
 
1976
  math-intrinsics@^1.1.0:
1977
  version "1.1.0"
1978
- resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
1979
  integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
1980
 
1981
  mdast-util-find-and-replace@^3.0.0:
@@ -2160,17 +2119,17 @@ mdast-util-to-string@^4.0.0:
2160
 
2161
  media-typer@0.3.0:
2162
  version "0.3.0"
2163
- resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
2164
  integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
2165
 
2166
  merge-descriptors@1.0.3:
2167
  version "1.0.3"
2168
- resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz"
2169
  integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
2170
 
2171
  methods@~1.1.2:
2172
  version "1.1.2"
2173
- resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
2174
  integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
2175
 
2176
  micromark-core-commonmark@^2.0.0:
@@ -2448,19 +2407,19 @@ micromark@^4.0.0:
2448
 
2449
  mime-db@1.52.0:
2450
  version "1.52.0"
2451
- resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
2452
  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
2453
 
2454
  mime-types@~2.1.24, mime-types@~2.1.34:
2455
  version "2.1.35"
2456
- resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
2457
  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
2458
  dependencies:
2459
  mime-db "1.52.0"
2460
 
2461
  mime@1.6.0:
2462
  version "1.6.0"
2463
- resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
2464
  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
2465
 
2466
  minimatch@^3.1.2:
@@ -2484,12 +2443,12 @@ motion-utils@^12.23.6:
2484
 
2485
  ms@2.0.0:
2486
  version "2.0.0"
2487
- resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
2488
  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
2489
 
2490
  ms@2.1.3, ms@^2.1.3:
2491
  version "2.1.3"
2492
- resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
2493
  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
2494
 
2495
  nanoid@^3.3.11:
@@ -2504,7 +2463,7 @@ natural-compare@^1.4.0:
2504
 
2505
  negotiator@0.6.3:
2506
  version "0.6.3"
2507
- resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
2508
  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
2509
 
2510
  node-releases@^2.0.27:
@@ -2519,12 +2478,12 @@ object-assign@^4.1.1:
2519
 
2520
  object-inspect@^1.13.3:
2521
  version "1.13.4"
2522
- resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz"
2523
  integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
2524
 
2525
  on-finished@~2.4.1:
2526
  version "2.4.1"
2527
- resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"
2528
  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
2529
  dependencies:
2530
  ee-first "1.1.1"
@@ -2587,7 +2546,7 @@ parse-json@^5.0.0:
2587
 
2588
  parseurl@~1.3.3:
2589
  version "1.3.3"
2590
- resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz"
2591
  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
2592
 
2593
  path-exists@^4.0.0:
@@ -2607,7 +2566,7 @@ path-parse@^1.0.7:
2607
 
2608
  path-to-regexp@~0.1.12:
2609
  version "0.1.12"
2610
- resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz"
2611
  integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==
2612
 
2613
  path-type@^4.0.0:
@@ -2655,7 +2614,7 @@ property-information@^7.0.0:
2655
 
2656
  proxy-addr@~2.0.7:
2657
  version "2.0.7"
2658
- resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz"
2659
  integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
2660
  dependencies:
2661
  forwarded "0.2.0"
@@ -2668,19 +2627,19 @@ punycode@^2.1.0:
2668
 
2669
  qs@~6.14.0:
2670
  version "6.14.1"
2671
- resolved "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz"
2672
  integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==
2673
  dependencies:
2674
  side-channel "^1.1.0"
2675
 
2676
  range-parser@~1.2.1:
2677
  version "1.2.1"
2678
- resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz"
2679
  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
2680
 
2681
  raw-body@~2.5.3:
2682
  version "2.5.3"
2683
- resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz"
2684
  integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==
2685
  dependencies:
2686
  bytes "~3.1.2"
@@ -2857,12 +2816,12 @@ rollup@^4.43.0:
2857
 
2858
  safe-buffer@5.2.1:
2859
  version "5.2.1"
2860
- resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
2861
  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
2862
 
2863
  "safer-buffer@>= 2.1.2 < 3":
2864
  version "2.1.2"
2865
- resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
2866
  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
2867
 
2868
  scheduler@^0.27.0:
@@ -2877,7 +2836,7 @@ semver@^6.3.1:
2877
 
2878
  send@~0.19.0, send@~0.19.1:
2879
  version "0.19.2"
2880
- resolved "https://registry.npmjs.org/send/-/send-0.19.2.tgz"
2881
  integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==
2882
  dependencies:
2883
  debug "2.6.9"
@@ -2896,7 +2855,7 @@ send@~0.19.0, send@~0.19.1:
2896
 
2897
  serve-static@~1.16.2:
2898
  version "1.16.3"
2899
- resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz"
2900
  integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==
2901
  dependencies:
2902
  encodeurl "~2.0.0"
@@ -2911,7 +2870,7 @@ set-cookie-parser@^2.6.0:
2911
 
2912
  setprototypeof@1.2.0, setprototypeof@~1.2.0:
2913
  version "1.2.0"
2914
- resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz"
2915
  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
2916
 
2917
  shebang-command@^2.0.0:
@@ -2928,7 +2887,7 @@ shebang-regex@^3.0.0:
2928
 
2929
  side-channel-list@^1.0.0:
2930
  version "1.0.0"
2931
- resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz"
2932
  integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
2933
  dependencies:
2934
  es-errors "^1.3.0"
@@ -2936,7 +2895,7 @@ side-channel-list@^1.0.0:
2936
 
2937
  side-channel-map@^1.0.1:
2938
  version "1.0.1"
2939
- resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz"
2940
  integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
2941
  dependencies:
2942
  call-bound "^1.0.2"
@@ -2946,7 +2905,7 @@ side-channel-map@^1.0.1:
2946
 
2947
  side-channel-weakmap@^1.0.2:
2948
  version "1.0.2"
2949
- resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz"
2950
  integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
2951
  dependencies:
2952
  call-bound "^1.0.2"
@@ -2957,7 +2916,7 @@ side-channel-weakmap@^1.0.2:
2957
 
2958
  side-channel@^1.1.0:
2959
  version "1.1.0"
2960
- resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz"
2961
  integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
2962
  dependencies:
2963
  es-errors "^1.3.0"
@@ -2983,18 +2942,9 @@ space-separated-tokens@^2.0.0:
2983
 
2984
  statuses@~2.0.1, statuses@~2.0.2:
2985
  version "2.0.2"
2986
- resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz"
2987
  integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
2988
 
2989
- string-width@^4.2.3:
2990
- version "4.2.3"
2991
- resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
2992
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
2993
- dependencies:
2994
- emoji-regex "^8.0.0"
2995
- is-fullwidth-code-point "^3.0.0"
2996
- strip-ansi "^6.0.1"
2997
-
2998
  stringify-entities@^4.0.0:
2999
  version "4.0.4"
3000
  resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz"
@@ -3003,13 +2953,6 @@ stringify-entities@^4.0.0:
3003
  character-entities-html4 "^2.0.0"
3004
  character-entities-legacy "^3.0.0"
3005
 
3006
- strip-ansi@^6.0.1:
3007
- version "6.0.1"
3008
- resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
3009
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
3010
- dependencies:
3011
- ansi-regex "^5.0.1"
3012
-
3013
  strip-json-comments@^3.1.1:
3014
  version "3.1.1"
3015
  resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
@@ -3056,7 +2999,7 @@ tinyglobby@^0.2.15:
3056
 
3057
  toidentifier@~1.0.1:
3058
  version "1.0.1"
3059
- resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
3060
  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
3061
 
3062
  trim-lines@^3.0.0:
@@ -3083,7 +3026,7 @@ type-check@^0.4.0, type-check@~0.4.0:
3083
 
3084
  type-is@~1.6.18:
3085
  version "1.6.18"
3086
- resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"
3087
  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
3088
  dependencies:
3089
  media-typer "0.3.0"
@@ -3150,7 +3093,7 @@ unist-util-visit@^5.0.0:
3150
 
3151
  unpipe@~1.0.0:
3152
  version "1.0.0"
3153
- resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
3154
  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
3155
 
3156
  update-browserslist-db@^1.2.0:
@@ -3170,12 +3113,12 @@ uri-js@^4.2.2:
3170
 
3171
  utils-merge@1.0.1:
3172
  version "1.0.1"
3173
- resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
3174
  integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
3175
 
3176
  vary@~1.1.2:
3177
  version "1.1.2"
3178
- resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
3179
  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
3180
 
3181
  vfile-message@^4.0.0:
 
470
  "@eslint/core" "^0.17.0"
471
  levn "^0.4.1"
472
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  "@humanfs/core@^0.19.1":
474
  version "0.19.1"
475
  resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"
 
908
 
909
  accepts@~1.3.8:
910
  version "1.3.8"
911
+ resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
912
  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
913
  dependencies:
914
  mime-types "~2.1.34"
 
934
  json-schema-traverse "^0.4.1"
935
  uri-js "^4.2.2"
936
 
 
 
 
 
 
937
  ansi-styles@^4.1.0:
938
  version "4.3.0"
939
  resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
 
948
 
949
  array-flatten@1.1.1:
950
  version "1.1.1"
951
+ resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
952
  integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
953
 
954
  babel-plugin-macros@^3.1.0:
 
977
 
978
  body-parser@~1.20.3:
979
  version "1.20.4"
980
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f"
981
  integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==
982
  dependencies:
983
  bytes "~3.1.2"
 
1014
 
1015
  bytes@~3.1.2:
1016
  version "3.1.2"
1017
+ resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
1018
  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
1019
 
1020
  call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
1021
  version "1.0.2"
1022
+ resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
1023
  integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
1024
  dependencies:
1025
  es-errors "^1.3.0"
 
1027
 
1028
  call-bound@^1.0.2:
1029
  version "1.0.4"
1030
+ resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
1031
  integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
1032
  dependencies:
1033
  call-bind-apply-helpers "^1.0.2"
 
1076
  resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz"
1077
  integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
1078
 
 
 
 
 
 
 
 
1079
  clsx@^2.1.1:
1080
  version "2.1.1"
1081
  resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
 
1105
 
1106
  content-disposition@~0.5.4:
1107
  version "0.5.4"
1108
+ resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
1109
  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
1110
  dependencies:
1111
  safe-buffer "5.2.1"
1112
 
1113
  content-type@~1.0.4, content-type@~1.0.5:
1114
  version "1.0.5"
1115
+ resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
1116
  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
1117
 
1118
  convert-source-map@^1.5.0:
 
1127
 
1128
  cookie-signature@~1.0.6:
1129
  version "1.0.7"
1130
+ resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454"
1131
  integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==
1132
 
1133
  cookie@^1.0.1:
 
1137
 
1138
  cookie@~0.7.1:
1139
  version "0.7.2"
1140
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
1141
  integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
1142
 
1143
  cosmiconfig@^7.0.0:
 
1167
 
1168
  debug@2.6.9:
1169
  version "2.6.9"
1170
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
1171
  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
1172
  dependencies:
1173
  ms "2.0.0"
 
1193
 
1194
  depd@2.0.0, depd@~2.0.0:
1195
  version "2.0.0"
1196
+ resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
1197
  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
1198
 
1199
  dequal@^2.0.0:
 
1203
 
1204
  destroy@1.2.0, destroy@~1.2.0:
1205
  version "1.2.0"
1206
+ resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
1207
  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
1208
 
1209
  devlop@^1.0.0, devlop@^1.1.0:
 
1223
 
1224
  dunder-proto@^1.0.1:
1225
  version "1.0.1"
1226
+ resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
1227
  integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
1228
  dependencies:
1229
  call-bind-apply-helpers "^1.0.1"
 
1232
 
1233
  ee-first@1.1.1:
1234
  version "1.1.1"
1235
+ resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
1236
  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
1237
 
1238
  electron-to-chromium@^1.5.263:
 
1240
  resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz"
1241
  integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
1242
 
 
 
 
 
 
1243
  encodeurl@~2.0.0:
1244
  version "2.0.0"
1245
+ resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
1246
  integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
1247
 
1248
  error-ex@^1.3.1:
 
1254
 
1255
  es-define-property@^1.0.1:
1256
  version "1.0.1"
1257
+ resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
1258
  integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
1259
 
1260
  es-errors@^1.3.0:
1261
  version "1.3.0"
1262
+ resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
1263
  integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
1264
 
1265
  es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
1266
  version "1.1.1"
1267
+ resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
1268
  integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
1269
  dependencies:
1270
  es-errors "^1.3.0"
 
1308
 
1309
  escape-html@~1.0.3:
1310
  version "1.0.3"
1311
+ resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
1312
  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
1313
 
1314
  escape-string-regexp@^4.0.0:
 
1435
 
1436
  etag@~1.8.1:
1437
  version "1.8.1"
1438
+ resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
1439
  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
1440
 
1441
  express@^4.21.2:
1442
  version "4.22.1"
1443
+ resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069"
1444
  integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==
1445
  dependencies:
1446
  accepts "~1.3.8"
 
1509
 
1510
  finalhandler@~1.3.1:
1511
  version "1.3.2"
1512
+ resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88"
1513
  integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==
1514
  dependencies:
1515
  debug "2.6.9"
 
1548
 
1549
  forwarded@0.2.0:
1550
  version "0.2.0"
1551
+ resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
1552
  integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
1553
 
1554
  framer-motion@^12.23.26:
 
1562
 
1563
  fresh@~0.5.2:
1564
  version "0.5.2"
1565
+ resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
1566
  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
1567
 
1568
  fsevents@~2.3.2, fsevents@~2.3.3:
 
1575
  resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
1576
  integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
1577
 
 
 
 
 
 
1578
  gensync@^1.0.0-beta.2:
1579
  version "1.0.0-beta.2"
1580
  resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
 
1582
 
1583
  get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
1584
  version "1.3.0"
1585
+ resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
1586
  integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
1587
  dependencies:
1588
  call-bind-apply-helpers "^1.0.2"
 
1598
 
1599
  get-proto@^1.0.1:
1600
  version "1.0.1"
1601
+ resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
1602
  integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
1603
  dependencies:
1604
  dunder-proto "^1.0.1"
 
1623
 
1624
  gopd@^1.2.0:
1625
  version "1.2.0"
1626
+ resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
1627
  integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
1628
 
1629
  has-flag@^4.0.0:
 
1633
 
1634
  has-symbols@^1.1.0:
1635
  version "1.1.0"
1636
+ resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
1637
  integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
1638
 
1639
  hasown@^2.0.2:
 
1719
 
1720
  http-errors@~2.0.0, http-errors@~2.0.1:
1721
  version "2.0.1"
1722
+ resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
1723
  integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
1724
  dependencies:
1725
  depd "~2.0.0"
 
1730
 
1731
  iconv-lite@~0.4.24:
1732
  version "0.4.24"
1733
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
1734
  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
1735
  dependencies:
1736
  safer-buffer ">= 2.1.2 < 3"
 
1755
 
1756
  inherits@~2.0.4:
1757
  version "2.0.4"
1758
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
1759
  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
1760
 
1761
  inline-style-parser@0.2.7:
 
1765
 
1766
  ipaddr.js@1.9.1:
1767
  version "1.9.1"
1768
+ resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
1769
  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
1770
 
1771
  is-alphabetical@^2.0.0:
 
1803
  resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
1804
  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
1805
 
 
 
 
 
 
1806
  is-glob@^4.0.0, is-glob@^4.0.3:
1807
  version "4.0.3"
1808
  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
 
1934
 
1935
  math-intrinsics@^1.1.0:
1936
  version "1.1.0"
1937
+ resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
1938
  integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
1939
 
1940
  mdast-util-find-and-replace@^3.0.0:
 
2119
 
2120
  media-typer@0.3.0:
2121
  version "0.3.0"
2122
+ resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
2123
  integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
2124
 
2125
  merge-descriptors@1.0.3:
2126
  version "1.0.3"
2127
+ resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
2128
  integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
2129
 
2130
  methods@~1.1.2:
2131
  version "1.1.2"
2132
+ resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
2133
  integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
2134
 
2135
  micromark-core-commonmark@^2.0.0:
 
2407
 
2408
  mime-db@1.52.0:
2409
  version "1.52.0"
2410
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
2411
  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
2412
 
2413
  mime-types@~2.1.24, mime-types@~2.1.34:
2414
  version "2.1.35"
2415
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
2416
  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
2417
  dependencies:
2418
  mime-db "1.52.0"
2419
 
2420
  mime@1.6.0:
2421
  version "1.6.0"
2422
+ resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
2423
  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
2424
 
2425
  minimatch@^3.1.2:
 
2443
 
2444
  ms@2.0.0:
2445
  version "2.0.0"
2446
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
2447
  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
2448
 
2449
  ms@2.1.3, ms@^2.1.3:
2450
  version "2.1.3"
2451
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
2452
  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
2453
 
2454
  nanoid@^3.3.11:
 
2463
 
2464
  negotiator@0.6.3:
2465
  version "0.6.3"
2466
+ resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
2467
  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
2468
 
2469
  node-releases@^2.0.27:
 
2478
 
2479
  object-inspect@^1.13.3:
2480
  version "1.13.4"
2481
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
2482
  integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
2483
 
2484
  on-finished@~2.4.1:
2485
  version "2.4.1"
2486
+ resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
2487
  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
2488
  dependencies:
2489
  ee-first "1.1.1"
 
2546
 
2547
  parseurl@~1.3.3:
2548
  version "1.3.3"
2549
+ resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
2550
  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
2551
 
2552
  path-exists@^4.0.0:
 
2566
 
2567
  path-to-regexp@~0.1.12:
2568
  version "0.1.12"
2569
+ resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7"
2570
  integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==
2571
 
2572
  path-type@^4.0.0:
 
2614
 
2615
  proxy-addr@~2.0.7:
2616
  version "2.0.7"
2617
+ resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
2618
  integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
2619
  dependencies:
2620
  forwarded "0.2.0"
 
2627
 
2628
  qs@~6.14.0:
2629
  version "6.14.1"
2630
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159"
2631
  integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==
2632
  dependencies:
2633
  side-channel "^1.1.0"
2634
 
2635
  range-parser@~1.2.1:
2636
  version "1.2.1"
2637
+ resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
2638
  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
2639
 
2640
  raw-body@~2.5.3:
2641
  version "2.5.3"
2642
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2"
2643
  integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==
2644
  dependencies:
2645
  bytes "~3.1.2"
 
2816
 
2817
  safe-buffer@5.2.1:
2818
  version "5.2.1"
2819
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
2820
  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
2821
 
2822
  "safer-buffer@>= 2.1.2 < 3":
2823
  version "2.1.2"
2824
+ resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
2825
  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
2826
 
2827
  scheduler@^0.27.0:
 
2836
 
2837
  send@~0.19.0, send@~0.19.1:
2838
  version "0.19.2"
2839
+ resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29"
2840
  integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==
2841
  dependencies:
2842
  debug "2.6.9"
 
2855
 
2856
  serve-static@~1.16.2:
2857
  version "1.16.3"
2858
+ resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9"
2859
  integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==
2860
  dependencies:
2861
  encodeurl "~2.0.0"
 
2870
 
2871
  setprototypeof@1.2.0, setprototypeof@~1.2.0:
2872
  version "1.2.0"
2873
+ resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
2874
  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
2875
 
2876
  shebang-command@^2.0.0:
 
2887
 
2888
  side-channel-list@^1.0.0:
2889
  version "1.0.0"
2890
+ resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
2891
  integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
2892
  dependencies:
2893
  es-errors "^1.3.0"
 
2895
 
2896
  side-channel-map@^1.0.1:
2897
  version "1.0.1"
2898
+ resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42"
2899
  integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
2900
  dependencies:
2901
  call-bound "^1.0.2"
 
2905
 
2906
  side-channel-weakmap@^1.0.2:
2907
  version "1.0.2"
2908
+ resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea"
2909
  integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
2910
  dependencies:
2911
  call-bound "^1.0.2"
 
2916
 
2917
  side-channel@^1.1.0:
2918
  version "1.1.0"
2919
+ resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9"
2920
  integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
2921
  dependencies:
2922
  es-errors "^1.3.0"
 
2942
 
2943
  statuses@~2.0.1, statuses@~2.0.2:
2944
  version "2.0.2"
2945
+ resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
2946
  integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
2947
 
 
 
 
 
 
 
 
 
 
2948
  stringify-entities@^4.0.0:
2949
  version "4.0.4"
2950
  resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz"
 
2953
  character-entities-html4 "^2.0.0"
2954
  character-entities-legacy "^3.0.0"
2955
 
 
 
 
 
 
 
 
2956
  strip-json-comments@^3.1.1:
2957
  version "3.1.1"
2958
  resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
 
2999
 
3000
  toidentifier@~1.0.1:
3001
  version "1.0.1"
3002
+ resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
3003
  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
3004
 
3005
  trim-lines@^3.0.0:
 
3026
 
3027
  type-is@~1.6.18:
3028
  version "1.6.18"
3029
+ resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
3030
  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
3031
  dependencies:
3032
  media-typer "0.3.0"
 
3093
 
3094
  unpipe@~1.0.0:
3095
  version "1.0.0"
3096
+ resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
3097
  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
3098
 
3099
  update-browserslist-db@^1.2.0:
 
3113
 
3114
  utils-merge@1.0.1:
3115
  version "1.0.1"
3116
+ resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
3117
  integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
3118
 
3119
  vary@~1.1.2:
3120
  version "1.1.2"
3121
+ resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
3122
  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
3123
 
3124
  vfile-message@^4.0.0: