.env.example DELETED
@@ -1,58 +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
36
-
37
- # -----------------------------------------------------------------------------
38
- # OpenAI Realtime ephemeral keys (used by /api/openai/ephemeral)
39
- # -----------------------------------------------------------------------------
40
- # Master OpenAI API key. Used SERVER-SIDE only to mint short-lived
41
- # (~1 minute) `client_secret.value` tokens for the Reachy Mini mobile
42
- # shell's voice conversation feature. NEVER expose this to a client
43
- # bundle. The mobile client never sees this value: it posts its HF
44
- # Bearer token, gets back an ephemeral session key scoped to a single
45
- # OpenAI Realtime session.
46
- OPENAI_API_KEY=
47
-
48
- # Optional: pin the Realtime model id. Defaults to
49
- # `gpt-4o-realtime-preview-2024-12-17`. Bumping this requires
50
- # coordinating with the mobile shell, which is built against a
51
- # specific protocol version.
52
- # OPENAI_REALTIME_MODEL=gpt-4o-realtime-preview-2024-12-17
53
-
54
- # Optional: max ephemeral mints per HF user per rolling hour.
55
- # Defaults to 60. The in-memory sliding-window limiter resets on
56
- # Space restart; v1 takes the trade-off, replace with a shared KV
57
- # the day we need multi-replica fairness.
58
- # OPENAI_EPHEMERAL_RATE_LIMIT_PER_HOUR=60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.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,16 +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
- "compression": "^1.8.1",
22
- "express": "^4.21.2",
23
  "framer-motion": "^12.23.26",
24
- "fuse.js": "^7.1.0",
25
  "highlight.js": "^11.11.1",
26
  "react": "^19.2.0",
27
  "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,212 +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
- * Public projection of the taxonomy meant to be shipped to clients
158
- * (mobile shell, website filter chips). We strip the `description`
159
- * field on purpose: it is sized + worded for the LLM prompt and
160
- * carries no UI value (clients render `label` + `emoji`). Render
161
- * order is the index in `CATEGORIES`, surfaced as `order` so a
162
- * client that needs to re-sort (e.g. alphabetical view) keeps the
163
- * canonical order one field-away.
164
- *
165
- * The shape is intentionally minimal and stable:
166
- * `{ slug, label, emoji, order }`. Adding optional fields later
167
- * (e.g. `color`, `shortLabel`) is forward-compatible; renaming or
168
- * dropping one is a breaking change for any client mirror.
169
- */
170
- export function getPublicTaxonomy() {
171
- return CATEGORIES.map((c, index) => ({
172
- slug: c.slug,
173
- label: c.label,
174
- emoji: c.emoji,
175
- order: index,
176
- }));
177
- }
178
-
179
- /**
180
- * Render the taxonomy as a bulleted list for the LLM prompt.
181
- * Format mirrors what the model is asked to output (slug first)
182
- * to nudge it towards copying the exact string back.
183
- */
184
- export function buildLlmCategoryList() {
185
- return CATEGORIES.map((c) => `- ${c.slug}: ${c.description}`).join('\n');
186
- }
187
-
188
- /**
189
- * Sanitize a raw LLM-returned list of slugs:
190
- * - drop non-strings
191
- * - lowercase + trim
192
- * - drop unknown slugs (hallucinations)
193
- * - dedupe while preserving order (the model orders by relevance)
194
- * - cap to MAX_CATEGORIES
195
- *
196
- * Returns a fresh array; never mutates input.
197
- */
198
- export function sanitizeSlugs(raw, maxCategories = 3) {
199
- if (!Array.isArray(raw)) return [];
200
- const seen = new Set();
201
- const out = [];
202
- for (const v of raw) {
203
- if (typeof v !== 'string') continue;
204
- const slug = v.trim().toLowerCase();
205
- if (!slug || seen.has(slug)) continue;
206
- if (!ALLOWED_SLUGS.has(slug)) continue;
207
- seen.add(slug);
208
- out.push(slug);
209
- if (out.length >= maxCategories) break;
210
- }
211
- return out;
212
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,255 +1,19 @@
1
- import compression from 'compression';
2
  import express from 'express';
3
- import { existsSync, readFileSync } from 'fs';
4
  import path from 'path';
5
  import { fileURLToPath } from 'url';
6
 
7
- import { categorizeApp, HfTokenMissingError } from './categorize.js';
8
- import { categoryCache } from './categoryCache.js';
9
- import { getPublicTaxonomy } from './categories.js';
10
- import { mintEphemeralKeyHandler } from './openaiEphemeral.js';
11
-
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
 
14
- // Load `.env` from the repo root in dev. In production (HF Space)
15
- // the platform already injects the secrets as env vars, so this
16
- // loader silently no-ops. We avoid the `dotenv` dep on purpose -
17
- // the format is trivial, and reproducing it inline keeps the
18
- // runtime closure tiny.
19
- (function loadDotenv() {
20
- try {
21
- const envPath = path.join(__dirname, '..', '.env');
22
- if (!existsSync(envPath)) return;
23
- const text = readFileSync(envPath, 'utf8');
24
- for (const line of text.split(/\r?\n/)) {
25
- const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i);
26
- if (!m) continue;
27
- const [, key, raw] = m;
28
- let value = raw;
29
- if (
30
- (value.startsWith('"') && value.endsWith('"')) ||
31
- (value.startsWith("'") && value.endsWith("'"))
32
- ) {
33
- value = value.slice(1, -1);
34
- }
35
- // Existing env wins (so `HF_TOKEN=foo node …` overrides .env).
36
- if (process.env[key] === undefined) process.env[key] = value;
37
- }
38
- } catch {
39
- /* best-effort - missing or malformed .env never blocks boot */
40
- }
41
- })();
42
-
43
  const app = express();
44
  const PORT = process.env.PORT || 7860;
45
 
46
- // gzip/brotli compression on every response. Critical for the
47
- // catalog endpoints (`/api/apps`, `/api/js-apps`) which return
48
- // ~40KB of JSON dominated by repeated keys ("apps", "id", "extra",
49
- // "cardData"…) - gzip cuts that to ~6KB on the wire. The Express
50
- // `compression` middleware:
51
- // - skips responses already encoded (no double-encoding),
52
- // - skips responses below the `threshold` (default 1KB - tiny
53
- // payloads stay verbatim since the gzip framing would dwarf
54
- // the savings),
55
- // - honours the client's `Accept-Encoding`, falling back to
56
- // identity when the client doesn't speak gzip/br.
57
- // No streaming endpoints in this server (every route ends in
58
- // `res.json()` or `res.sendFile()`), so compression is unconditionally
59
- // safe. The default `level: 6` is the right CPU/ratio trade-off for
60
- // JSON.
61
- app.use(compression());
62
-
63
- // JSON body parsing for the handful of POST routes that consume
64
- // structured payloads (currently `/api/openai/ephemeral`). The 8KB
65
- // cap is intentionally tiny because none of our endpoints accept
66
- // large bodies, and a tight limit drops obvious abuse early.
67
- app.use(express.json({ limit: '8kb' }));
68
-
69
- // CORS allowlist for cross-origin API consumers. Same-origin browser
70
- // calls from this Space stay unaffected. The mobile shell runs from
71
- // `https://tauri.localhost` (iOS WKWebView), `http://tauri.localhost`
72
- // (Android WebView), and the desktop dev preview from
73
- // `http://localhost:1422` (Vite). We do NOT use a wildcard origin
74
- // because every allowed call expects `Authorization: Bearer …`, and
75
- // `Access-Control-Allow-Origin: *` is incompatible with credentialed
76
- // CORS in any practical setup.
77
- const CORS_ALLOWED_ORIGINS = new Set([
78
- 'https://tauri.localhost',
79
- 'http://tauri.localhost',
80
- 'http://localhost:1422',
81
- 'http://localhost:1420',
82
- ]);
83
-
84
- app.use((req, res, next) => {
85
- const origin = req.headers.origin;
86
- if (origin && CORS_ALLOWED_ORIGINS.has(origin)) {
87
- res.setHeader('Access-Control-Allow-Origin', origin);
88
- res.setHeader('Vary', 'Origin');
89
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
90
- res.setHeader(
91
- 'Access-Control-Allow-Headers',
92
- 'Authorization, Content-Type',
93
- );
94
- // Expose `Age` so cross-origin JS clients (mobile shell, desktop
95
- // store, anything not running same-origin on this Space) can
96
- // read the server-side cache age. The header lives in the
97
- // CORS-safelisted set only for a hardcoded handful of fields;
98
- // `Age` is NOT in that set, so without this header browser
99
- // `fetch()` callers would see `null` from `headers.get('age')`.
100
- // We could also expose `ETag` here for clients that want to
101
- // do manual `If-None-Match` revalidation, but the browser
102
- // handles ETag transparently in its own HTTP cache, so JS
103
- // never needs to see it.
104
- res.setHeader('Access-Control-Expose-Headers', 'Age');
105
- res.setHeader('Access-Control-Max-Age', '600');
106
- }
107
- if (req.method === 'OPTIONS') return res.sendStatus(204);
108
- next();
109
- });
110
-
111
  // Cache configuration
112
- const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
113
  const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json';
114
  const HF_SPACES_API = 'https://huggingface.co/api/spaces';
115
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
116
  const HF_SPACES_LIMIT = 1000;
117
 
118
- /**
119
- * Standard HTTP caching for the catalog GET endpoints
120
- * (`/api/apps`, `/api/js-apps`).
121
- *
122
- * Why bake this into a helper instead of inlining the same two
123
- * `setHeader` calls in every route:
124
- * 1. Both endpoints share the same upstream cache state
125
- * (`appsCache.lastFetch`) so they SHOULD emit a coherent
126
- * `Age` value - any drift between routes would silently
127
- * mislead clients about cache staleness.
128
- * 2. The `Cache-Control` directives below were chosen carefully;
129
- * a future contributor copy-pasting one route to start a new
130
- * catalog projection should inherit them rather than rolling
131
- * their own.
132
- *
133
- * Cache-Control: `public, max-age=60, stale-while-revalidate=300`
134
- * - `public`: response is safe to store in shared caches (the
135
- * payload is identical for every caller, no per-user data).
136
- * - `max-age=60`: clients + intermediaries may serve this
137
- * response for up to 60 s without revalidating. The upstream
138
- * `appsCache` already deduplicates within a 5-minute window
139
- * server-side, so 60 s here means the network sees at most
140
- * 1 hit/minute per cache key per intermediate even under
141
- * burst load (10k mobile shells waking up at the same time).
142
- * - `stale-while-revalidate=300`: for a further 5 minutes after
143
- * the response goes stale, intermediaries may serve the
144
- * stale copy while revalidating in the background. This
145
- * absorbs sudden traffic spikes without ever blocking the
146
- * user on a cold-cache fetch.
147
- *
148
- * `Age` (RFC 7234 §5.1) replaces the `cacheAge` field we used to
149
- * pack into the response body. Pulling the age out of the body
150
- * was a strict prerequisite for ETag-based revalidation: Express's
151
- * default ETag is a hash of the response body, and a body that
152
- * carries a counter that increments every second produces a fresh
153
- * ETag every second, which makes `If-None-Match` permanently
154
- * negative and turns the ETag into dead weight. With `cacheAge`
155
- * promoted to a header, the body becomes a pure function of the
156
- * cache contents, the ETag becomes stable across requests that
157
- * hit the same cache snapshot, and clients sending `If-None-Match`
158
- * get cheap 304s instead of re-downloading 40 KB of JSON.
159
- */
160
- function setCatalogCacheHeaders(res, lastFetchMs) {
161
- res.setHeader(
162
- 'Cache-Control',
163
- 'public, max-age=60, stale-while-revalidate=300',
164
- );
165
- const ageSeconds = lastFetchMs
166
- ? Math.max(0, Math.round((Date.now() - lastFetchMs) / 1000))
167
- : 0;
168
- res.setHeader('Age', String(ageSeconds));
169
- }
170
-
171
- // Tag that gates the JS-only subset surfaced by /api/js-apps and
172
- // fed to the LLM categorizer. Mirrors the filter the mobile shell
173
- // applies today client-side; the route lets us retire that filter
174
- // from the mobile codebase down the line.
175
- const JS_APP_TAG = 'reachy_mini_js_app';
176
-
177
- // =====================================================================
178
- // App icon convention
179
- // =====================================================================
180
- //
181
- // Convention: an app commits `public/icon.svg` (preferred) or
182
- // `public/icon.png` in its HF Space repository. When present, the
183
- // mobile shell + desktop store render it as the app glyph instead
184
- // of the front-matter `emoji:` codepoint.
185
- //
186
- // Why `public/` and not the repo root?
187
- // - Vite already copies `public/*` verbatim to `dist/` at build,
188
- // where nginx serves it at `/icon.svg`. The same file is
189
- // therefore the favicon, the `mountHost({ appIconUrl })` value,
190
- // AND the store glyph - one source of truth, no `cp` script,
191
- // no risk of the two copies drifting apart.
192
- // - HF `resolve/main/public/icon.svg` works the same as
193
- // `resolve/main/icon.svg`: any path inside the repo is
194
- // reachable, so the catalog still grabs the bytes without
195
- // waking the Space's nginx.
196
- //
197
- // We resolve the icon ONCE at indexing time (here) rather than
198
- // probing per-client because:
199
- // 1. We already pull `siblings` from `?full=true` (one cheap
200
- // hub call returns the file list for every app), so the
201
- // lookup is a pure JS filter, no extra network.
202
- // 2. Clients see a single field (`iconUrl`) in the payload and
203
- // don't have to know about HF resolve URLs, LFS pointers,
204
- // or the candidate-order race ("SVG wins if both exist").
205
- // 3. The HF API caps probes at ~hub side; doing it server-side
206
- // keeps fanout under a 5-minute TTL behind ONE token, instead
207
- // of every mobile shell hammering `huggingface.co/resolve/`
208
- // to discover icons.
209
- //
210
- // Resolution order: `public/icon.svg` → `public/icon.png`. SVG
211
- // first because the same asset scales cleanly across every mount
212
- // point (small rail tile, larger pinned tile, iframe header) from
213
- // a single file. Extra formats can be added to `ICON_CANDIDATES`
214
- // if needed; order matters - the first match wins.
215
- const ICON_CANDIDATES = ['public/icon.svg', 'public/icon.png'];
216
-
217
- /**
218
- * Look for a standard app icon file at the conventional location.
219
- * Returns the absolute HF resolve URL when found, `null` otherwise.
220
- *
221
- * We hit `resolve/main/` (not `raw/main/`) so:
222
- * - LFS pointers follow transparently (large PNGs work).
223
- * - `Content-Type` comes from the extension, which `<img>` needs.
224
- * - The URL is cacheable cross-session by the browser, so
225
- * repeated mounts of the same app glyph don't re-fetch.
226
- */
227
- function findIconUrl(spaceId, siblings) {
228
- if (!spaceId || !Array.isArray(siblings)) return null;
229
- // Build a Set of repo-relative filenames for O(1) candidate
230
- // lookups. HF returns `siblings` as `[{ rfilename: "path/in/repo" }, ...]`;
231
- // we keep the full path because the convention now lives under
232
- // `public/` rather than at the repo root.
233
- const files = new Set();
234
- for (const s of siblings) {
235
- const name = s && typeof s.rfilename === 'string' ? s.rfilename : null;
236
- if (!name) continue;
237
- files.add(name);
238
- }
239
- for (const candidate of ICON_CANDIDATES) {
240
- if (files.has(candidate)) {
241
- return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`;
242
- }
243
- }
244
- return null;
245
- }
246
-
247
- // Serialised LLM batch concurrency: we want at most one
248
- // categorization sweep running at a time, regardless of how many
249
- // /api/js-apps requests come in. The flag also prevents the
250
- // startup warm-up and an on-demand refresh from racing each other.
251
- let categorizationBatchRunning = false;
252
-
253
  // In-memory cache
254
  let appsCache = {
255
  data: null,
@@ -258,7 +22,6 @@ let appsCache = {
258
  };
259
 
260
  // Fetch apps from HuggingFace API
261
- // Returns format compatible with desktop app (with url, source_kind, extra)
262
  async function fetchAppsFromHF() {
263
  console.log('[Cache] Fetching apps from HuggingFace API...');
264
 
@@ -280,64 +43,33 @@ async function fetchAppsFromHF() {
280
  const allSpaces = await spacesResponse.json();
281
  console.log(`[Cache] Fetched ${allSpaces.length} spaces from HuggingFace`);
282
 
283
- // 3. Build apps list in desktop-compatible format
284
  const allApps = allSpaces.map(space => {
285
  const spaceId = space.id || '';
286
  const tags = space.tags || [];
287
  const isOfficial = officialSet.has(spaceId.toLowerCase());
288
  const isPythonApp = tags.includes('reachy_mini_python_app');
289
- const author = spaceId.split('/')[0];
290
- const name = spaceId.split('/').pop();
291
 
292
- // Server-resolved icon URL. Looks for `public/icon.svg` or
293
- // `public/icon.png` via the `siblings` list returned by
294
- // `?full=true`. See `findIconUrl()` above for the rationale.
295
- // `null` when the author hasn't shipped one; clients fall
296
- // back to the front-matter emoji.
297
- const iconUrl = findIconUrl(spaceId, space.siblings);
298
-
299
  return {
300
- // Core fields (used by both website and desktop)
301
  id: spaceId,
302
- name,
303
  description: space.cardData?.short_description || '',
304
- url: `https://huggingface.co/spaces/${spaceId}`,
305
- source_kind: 'hf_space',
 
 
306
  isOfficial,
307
- iconUrl,
308
-
309
- // Extra metadata (desktop-compatible structure)
310
- extra: {
311
- id: spaceId,
312
- author,
313
- likes: space.likes || 0,
314
- downloads: space.downloads || 0,
315
- createdAt: space.createdAt || null,
316
- lastModified: space.lastModified,
317
- runtime: space.runtime || null,
318
- tags,
319
- isPythonApp,
320
- cardData: {
321
- emoji: space.cardData?.emoji || (isPythonApp ? '📦' : '🌐'),
322
- short_description: space.cardData?.short_description || '',
323
- sdk: space.cardData?.sdk || null,
324
- tags: space.cardData?.tags || [],
325
- // Preserve other cardData fields
326
- ...space.cardData,
327
- },
328
- },
329
  };
330
  });
331
 
332
- console.log(`[Cache] Built ${allApps.length} raw app entries from HF.`);
333
-
334
- // Sort: official first, then by likes. Dedup is route-specific
335
- // and applied downstream (see `dedupGlobalApps` and `dedupJsApps`).
336
  allApps.sort((a, b) => {
337
  if (a.isOfficial !== b.isOfficial) {
338
  return a.isOfficial ? -1 : 1;
339
  }
340
- return (b.extra.likes || 0) - (a.extra.likes || 0);
341
  });
342
 
343
  return allApps;
@@ -347,68 +79,8 @@ async function fetchAppsFromHF() {
347
  }
348
  }
349
 
350
- /**
351
- * Pick a winner among Spaces sharing the same repo name. Forks
352
- * keep the upstream name (e.g. several `reachy_mini_conversation_app`
353
- * from different authors); we surface only one in the store to
354
- * avoid drowning the original under a dozen near-identical tiles.
355
- *
356
- * Priority: 1) official, 2) oldest (likely original), 3) most likes
357
- * as tiebreaker.
358
- */
359
- function dedupAppsByName(apps) {
360
- const deduped = new Map();
361
- for (const app of apps) {
362
- const key = app.name.toLowerCase();
363
- const existing = deduped.get(key);
364
- if (!existing) {
365
- deduped.set(key, app);
366
- continue;
367
- }
368
- if (app.isOfficial && !existing.isOfficial) {
369
- deduped.set(key, app);
370
- continue;
371
- }
372
- if (existing.isOfficial) continue;
373
- const appDate = app.extra?.createdAt ? new Date(app.extra.createdAt).getTime() : Infinity;
374
- const existingDate = existing.extra?.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity;
375
- if (appDate < existingDate) {
376
- deduped.set(key, app);
377
- } else if (appDate === existingDate && (app.extra?.likes || 0) > (existing.extra?.likes || 0)) {
378
- deduped.set(key, app);
379
- }
380
- }
381
- return [...deduped.values()];
382
- }
383
-
384
- /**
385
- * Dedup applied to the full `/api/apps` payload (Python + JS + others
386
- * mixed). Same-name collisions across SDKs collapse here too, by design:
387
- * the showcase site favours a clean catalog over completeness, and
388
- * SDK-aware variants of the same idea live as separate Spaces only
389
- * by accident in practice.
390
- */
391
- function dedupGlobalApps(apps) {
392
- return dedupAppsByName(apps);
393
- }
394
-
395
- /**
396
- * Dedup applied to the `/api/js-apps` route only. We restrict the
397
- * comparison to entries already filtered to the JS subset, so a JS
398
- * Space (e.g. `tfrere/emotions`) does not lose a name fight against
399
- * an unrelated Python Space sharing the same repo name (e.g.
400
- * `RemiFabre/emotions`). The mobile shell only sees JS apps anyway,
401
- * so confining dedup to that scope is what matches the user model.
402
- */
403
- function dedupJsApps(jsApps) {
404
- return dedupAppsByName(jsApps);
405
- }
406
-
407
- // Get raw apps with caching. Dedup is NOT applied here - each
408
- // route owns its own dedup policy (see `dedupGlobalApps` and
409
- // `dedupJsApps`) so they can disagree without paying for two
410
- // upstream fetches.
411
- async function getRawApps() {
412
  const now = Date.now();
413
 
414
  // Return cache if valid
@@ -430,7 +102,7 @@ async function getRawApps() {
430
  const apps = await fetchAppsFromHF();
431
  appsCache.data = apps;
432
  appsCache.lastFetch = now;
433
- console.log(`[Cache] Cache updated with ${apps.length} raw entries`);
434
  return apps;
435
  } catch (err) {
436
  // On error, return stale cache if available
@@ -447,12 +119,11 @@ async function getRawApps() {
447
  // API endpoint
448
  app.get('/api/apps', async (req, res) => {
449
  try {
450
- const raw = await getRawApps();
451
- const apps = dedupGlobalApps(raw);
452
- setCatalogCacheHeaders(res, appsCache.lastFetch);
453
  res.json({
454
  apps,
455
  cached: true,
 
456
  count: apps.length,
457
  });
458
  } catch (err) {
@@ -461,289 +132,6 @@ app.get('/api/apps', async (req, res) => {
461
  }
462
  });
463
 
464
- // =====================================================================
465
- // JS apps + LLM-inferred categories
466
- // =====================================================================
467
- //
468
- // `/api/js-apps` is a curated view on the JS-only subset:
469
- // 1. Filter on the `reachy_mini_js_app` tag (the mobile-embeddable subset).
470
- // 2. Dedup name collisions among JS apps only (`dedupJsApps`),
471
- // so a JS app does not get knocked out by a same-named Python
472
- // Space surfaced through `/api/apps`.
473
- // 3. Enrich each entry with `categories` + `categories_source`,
474
- // sourced from a persistent dataset cache (see categoryCache.js).
475
- //
476
- // Categories are inferred lazily by an LLM from each Space's
477
- // README. The first request after a cold start may see entries
478
- // with `categories: null` while the warmup batch is still in
479
- // flight; subsequent requests pick them up as the cache fills.
480
-
481
- /**
482
- * Pull the JS-app subset out of the raw apps cache, dedup it
483
- * within the JS scope, and fold in cached categories. Pure,
484
- * synchronous-ish (the only async call is to `getRawApps()` which
485
- * has its own cache).
486
- */
487
- async function getJsApps() {
488
- const raw = await getRawApps();
489
- const jsApps = raw.filter((a) => {
490
- const tags = a?.extra?.tags;
491
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
492
- });
493
- const deduped = dedupJsApps(jsApps);
494
-
495
- return deduped.map((app) => {
496
- const cached = categoryCache.get(app.id);
497
- return {
498
- ...app,
499
- categories: cached ? cached.categories : null,
500
- categories_source: cached ? 'inferred' : null,
501
- categorized_at: cached ? cached.categorizedAt : null,
502
- };
503
- });
504
- }
505
-
506
- /**
507
- * Run one classification pass over `jsApps`. Skips entries whose
508
- * cache is still fresh (same `lastModified`, same taxonomy).
509
- *
510
- * Serial on purpose: HF Inference Providers don't love bursts
511
- * from a single token, and total throughput on ~50 apps stays
512
- * well under a minute. We slip a small jitter between calls to
513
- * smooth the curve further.
514
- */
515
- async function runCategorizationBatch(jsApps) {
516
- if (categorizationBatchRunning) {
517
- console.log('[Categorize] Batch already running, skipping.');
518
- return;
519
- }
520
- if (!process.env.HF_TOKEN) {
521
- console.warn(
522
- '[Categorize] HF_TOKEN not set; skipping batch. Set it in .env ' +
523
- 'or the Space secrets to enable category inference.',
524
- );
525
- return;
526
- }
527
-
528
- const todo = jsApps.filter((app) =>
529
- categoryCache.needsCategorization(app.id, app?.extra?.lastModified),
530
- );
531
-
532
- if (todo.length === 0) {
533
- console.log(
534
- `[Categorize] All ${jsApps.length} JS apps are already categorized.`,
535
- );
536
- return;
537
- }
538
-
539
- categorizationBatchRunning = true;
540
- console.log(
541
- `[Categorize] Starting batch: ${todo.length}/${jsApps.length} app(s) need classification.`,
542
- );
543
-
544
- let success = 0;
545
- let failed = 0;
546
- let aborted = false;
547
-
548
- for (let i = 0; i < todo.length; i++) {
549
- const app = todo[i];
550
- const desc =
551
- app.description ||
552
- app.extra?.cardData?.short_description ||
553
- '';
554
- try {
555
- const slugs = await categorizeApp({
556
- spaceId: app.id,
557
- name: app.name,
558
- description: desc,
559
- });
560
- if (slugs == null) {
561
- failed++;
562
- console.log(
563
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: transient failure, will retry next pass`,
564
- );
565
- } else {
566
- categoryCache.set(app.id, {
567
- categories: slugs,
568
- lastModified: app.extra?.lastModified || null,
569
- });
570
- success++;
571
- console.log(
572
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: ${
573
- slugs.length ? slugs.join(', ') : '(no fit)'
574
- }`,
575
- );
576
- }
577
- } catch (err) {
578
- if (err instanceof HfTokenMissingError) {
579
- console.warn(
580
- '[Categorize] HF_TOKEN missing mid-batch; aborting cleanly.',
581
- );
582
- aborted = true;
583
- break;
584
- }
585
- failed++;
586
- console.warn(
587
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: error - ${err.message}`,
588
- );
589
- }
590
-
591
- // 250 ms cooldown between calls. Below this, the HF Provider
592
- // router occasionally rate-limits a hot token.
593
- await new Promise((resolve) => setTimeout(resolve, 250));
594
- }
595
-
596
- console.log(
597
- `[Categorize] Batch done: ${success} ok, ${failed} failed${aborted ? ' (aborted)' : ''}.`,
598
- );
599
- // Persist the new entries even if some failed - partial
600
- // progress is strictly better than none, and the failed
601
- // entries will be retried on the next pass.
602
- await categoryCache.flush();
603
-
604
- categorizationBatchRunning = false;
605
- }
606
-
607
- /**
608
- * Wrap the diagnostic snapshot for the API payload. Lets
609
- * consumers (mobile shell, website) decide whether to show
610
- * "loading categories..." or render chips immediately.
611
- */
612
- function buildCategorizationStats(jsApps) {
613
- let withCategories = 0;
614
- for (const app of jsApps) {
615
- if (app.categories && app.categories.length >= 0 && app.categories_source) {
616
- withCategories++;
617
- }
618
- }
619
- return {
620
- enabled: !!process.env.HF_TOKEN,
621
- total: jsApps.length,
622
- classified: withCategories,
623
- pending: jsApps.length - withCategories,
624
- inProgress: categorizationBatchRunning,
625
- // Authoritative taxonomy shipped alongside the apps so the
626
- // mobile shell (and any future client) doesn't have to mirror
627
- // the slug list by hand. Pairs with `taxonomyVersion` from
628
- // `categoryCache.stats()` so clients can detect drift between
629
- // the catalog payload and a stale on-device cache.
630
- taxonomy: getPublicTaxonomy(),
631
- ...categoryCache.stats(),
632
- };
633
- }
634
-
635
- app.get('/api/js-apps', async (req, res) => {
636
- try {
637
- const apps = await getJsApps();
638
-
639
- // Background top-up: if any entry is still uncategorized
640
- // (or a Space's lastModified moved since we last looked),
641
- // fire off a batch. We DO NOT await it - the response goes
642
- // out immediately with whatever the cache currently knows.
643
- const needsWork = apps.some(
644
- (a) =>
645
- !a.categories_source ||
646
- categoryCache.needsCategorization(a.id, a.extra?.lastModified),
647
- );
648
- if (needsWork) {
649
- // `void` to make it crystal clear we don't expect a value;
650
- // the batch logs its own progress.
651
- void runCategorizationBatch(apps).catch((err) => {
652
- console.error('[Categorize] Background batch crashed:', err);
653
- });
654
- }
655
-
656
- setCatalogCacheHeaders(res, appsCache.lastFetch);
657
- res.json({
658
- apps,
659
- cached: true,
660
- count: apps.length,
661
- categorization: buildCategorizationStats(apps),
662
- });
663
- } catch (err) {
664
- console.error('[API] /api/js-apps error:', err);
665
- res.status(500).json({ error: 'Failed to fetch JS apps' });
666
- }
667
- });
668
-
669
- // =====================================================================
670
- // Public taxonomy endpoint
671
- // =====================================================================
672
- //
673
- // Standalone read-only projection of the closed category taxonomy
674
- // (`server/categories.js`). Lets clients consume the slug list,
675
- // labels and emojis without paying the cost of a full apps fetch -
676
- // useful for early UI scaffolding (filter chips, empty states) and
677
- // for tooling that lints app metadata against the live taxonomy.
678
- //
679
- // `/api/js-apps` ALSO embeds the same payload under
680
- // `categorization.taxonomy`, so a mobile shell that fetches the
681
- // catalog never needs a second round-trip. This endpoint exists
682
- // for the "I just want the categories" use case.
683
- //
684
- // Cache headers: 5 minutes, same TTL as the catalog. The taxonomy
685
- // is stable across many catalog refreshes (it only moves when we
686
- // bump `TAXONOMY_VERSION`), but co-aligning the TTLs keeps the
687
- // reasoning simple - a client that polls both gets a coherent view.
688
- app.get('/api/categories', (_req, res) => {
689
- res.set('Cache-Control', 'public, max-age=300');
690
- const stats = categoryCache.stats();
691
- res.json({
692
- taxonomy: getPublicTaxonomy(),
693
- taxonomyVersion: stats.taxonomyVersion,
694
- });
695
- });
696
-
697
- // Manual trigger for a categorization sweep, useful when
698
- // hand-tuning the taxonomy or testing the LLM prompt without
699
- // waiting for the next /api/js-apps hit.
700
- app.post('/api/js-apps/refresh-categories', async (req, res) => {
701
- try {
702
- const apps = await getJsApps();
703
- void runCategorizationBatch(apps).catch((err) => {
704
- console.error('[Categorize] Manual batch crashed:', err);
705
- });
706
- res.json({
707
- ok: true,
708
- message: `Categorization batch kicked off for ${apps.length} JS apps.`,
709
- stats: buildCategorizationStats(apps),
710
- });
711
- } catch (err) {
712
- res.status(500).json({ error: 'Failed to trigger refresh' });
713
- }
714
- });
715
-
716
- // =====================================================================
717
- // OpenAI Realtime ephemeral keys
718
- // =====================================================================
719
- //
720
- // Per-user mint endpoint backing the Reachy Mini mobile shell's
721
- // voice conversation. The mobile client posts its HF Bearer token,
722
- // we validate it via `whoami-v2`, rate-limit per HF user, and
723
- // proxy a `POST /v1/realtime/sessions` to OpenAI with the master
724
- // `OPENAI_API_KEY` from this Space's secrets. The short-lived
725
- // `client_secret.value` is forwarded back to the client.
726
- //
727
- // See `server/openaiEphemeral.js` for the full design notes
728
- // (auth, caching, rate-limit shape, error mapping).
729
- app.post('/api/openai/ephemeral', mintEphemeralKeyHandler);
730
-
731
- // OAuth config endpoint - expose public OAuth variables to the frontend
732
- // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces)
733
- app.get('/api/oauth-config', (req, res) => {
734
- const clientId = process.env.OAUTH_CLIENT_ID;
735
- const scopes = process.env.OAUTH_SCOPES || 'openid profile';
736
-
737
- if (!clientId) {
738
- return res.status(503).json({
739
- error: 'OAuth not configured',
740
- hint: 'Make sure hf_oauth: true is set in README.md and the Space has been rebuilt',
741
- });
742
- }
743
-
744
- res.json({ clientId, scopes });
745
- });
746
-
747
  // Health check
748
  app.get('/api/health', (req, res) => {
749
  res.json({
@@ -758,7 +146,7 @@ app.get('/api/health', (req, res) => {
758
  app.post('/api/refresh', async (req, res) => {
759
  try {
760
  appsCache.lastFetch = null; // Invalidate cache
761
- const apps = await getRawApps();
762
  res.json({ success: true, count: apps.length });
763
  } catch (err) {
764
  res.status(500).json({ error: 'Failed to refresh cache' });
@@ -780,31 +168,8 @@ app.get('*', (req, res) => {
780
  async function warmCache() {
781
  console.log('[Startup] Pre-warming cache...');
782
  try {
783
- const apps = await getRawApps();
784
  console.log('[Startup] Cache warmed successfully');
785
-
786
- // Categorization warm-up: fire the JS-app batch in the
787
- // background so the first /api/js-apps caller doesn't
788
- // shoulder the cold-start cost. Order: load the dataset
789
- // cache first (cheap, one HTTP call), then run the batch
790
- // for stale entries only.
791
- void (async () => {
792
- try {
793
- await categoryCache.load();
794
- const jsApps = dedupJsApps(
795
- apps.filter((a) => {
796
- const tags = a?.extra?.tags;
797
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
798
- }),
799
- );
800
- console.log(
801
- `[Startup] Found ${jsApps.length} JS apps; checking categories...`,
802
- );
803
- await runCategorizationBatch(jsApps);
804
- } catch (err) {
805
- console.error('[Startup] Categorization warm-up failed:', err);
806
- }
807
- })();
808
  } catch (err) {
809
  console.error('[Startup] Failed to warm cache:', err);
810
  }
 
 
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;
 
79
  }
80
  }
81
 
82
+ // Get apps with caching
83
+ async function getApps() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  const now = Date.now();
85
 
86
  // Return cache if valid
 
102
  const apps = await fetchAppsFromHF();
103
  appsCache.data = apps;
104
  appsCache.lastFetch = now;
105
+ console.log(`[Cache] Cache updated with ${apps.length} apps`);
106
  return apps;
107
  } catch (err) {
108
  // On error, return stale cache if available
 
119
  // API endpoint
120
  app.get('/api/apps', async (req, res) => {
121
  try {
122
+ const apps = await getApps();
 
 
123
  res.json({
124
  apps,
125
  cached: true,
126
+ cacheAge: appsCache.lastFetch ? Math.round((Date.now() - appsCache.lastFetch) / 1000) : 0,
127
  count: apps.length,
128
  });
129
  } catch (err) {
 
132
  }
133
  });
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  // Health check
136
  app.get('/api/health', (req, res) => {
137
  res.json({
 
146
  app.post('/api/refresh', async (req, res) => {
147
  try {
148
  appsCache.lastFetch = null; // Invalidate cache
149
+ const apps = await getApps();
150
  res.json({ success: true, count: apps.length });
151
  } catch (err) {
152
  res.status(500).json({ error: 'Failed to refresh cache' });
 
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
  }
server/openaiEphemeral.js DELETED
@@ -1,268 +0,0 @@
1
- /**
2
- * Mint per-user OpenAI Realtime ephemeral session keys.
3
- *
4
- * Why this module exists
5
- * ----------------------
6
- * The Reachy Mini mobile shell historically baked a long-lived
7
- * OpenAI API key into the bundle (`VITE_OPENAI_API_KEY`), which
8
- * violates OpenAI's terms of service and leaks the key the moment
9
- * anyone extracts the IPA/APK. This module is the server-side
10
- * replacement:
11
- *
12
- * - The master `OPENAI_API_KEY` stays in this Space's secrets,
13
- * never reachable by any client.
14
- * - The mobile shell asks for a short-lived (~1 minute)
15
- * `client_secret.value` per voice conversation, signed with
16
- * the user's Hugging Face token so we can identify + rate-limit
17
- * per HF user.
18
- * - Each ephemeral key is scoped to a single OpenAI Realtime
19
- * session, so a leak only loses ~60 seconds of model access.
20
- *
21
- * Wire format (matches OpenAI's Realtime API)
22
- * -------------------------------------------
23
- * POST /api/openai/ephemeral
24
- * Authorization: Bearer <hf_token>
25
- * Content-Type: application/json
26
- * Body: { "model"?: string, "voice"?: string }
27
- *
28
- * 200 -> the full payload from
29
- * `POST https://api.openai.com/v1/realtime/client_secrets`,
30
- * forwarded as-is. The client uses `payload.value` (the
31
- * `ek_…` ephemeral token) for the `POST /v1/realtime/calls`
32
- * WebRTC handshake.
33
- * 401 -> missing/invalid HF token
34
- * 429 -> per-user rate limit hit
35
- * 502 -> OpenAI upstream failed (key bad, model down, ...)
36
- * 503 -> OPENAI_API_KEY missing on the Space
37
- *
38
- * Why we trust HF for auth
39
- * ------------------------
40
- * The mobile shell already requires a Hugging Face sign-in to
41
- * pair with a Reachy Mini robot, so the HF token is a free
42
- * identity primitive: every legitimate caller already has one,
43
- * and HF can revoke it from their side. We resolve the token via
44
- * `whoami-v2` once per 5 minutes (cached) and use the returned
45
- * `name` as the rate-limit bucket key.
46
- */
47
-
48
- // GA endpoint. The legacy Beta endpoint
49
- // (`POST /v1/realtime/sessions`) was retired on 2026-05-07
50
- // alongside the `OpenAI-Beta: realtime=v1` header, and only
51
- // accepted preview models (`gpt-4o-realtime-preview-*`). The GA
52
- // endpoint takes a `session` envelope with a required `type`
53
- // discriminator and returns the ephemeral key at the top level
54
- // (`{ value, expires_at, session }`).
55
- const OPENAI_CLIENT_SECRETS_URL =
56
- 'https://api.openai.com/v1/realtime/client_secrets';
57
- const HF_WHOAMI_URL = 'https://huggingface.co/api/whoami-v2';
58
-
59
- // In-memory whoami cache. HF's whoami round-trip is ~150ms, and
60
- // caching it keeps the mint endpoint snappy without breaking
61
- // revocation in practice: HF's own token-revocation cache is
62
- // already eventually consistent, and our 5-minute staleness sits
63
- // well inside that.
64
- const whoamiCache = new Map();
65
- const WHOAMI_TTL_MS = 5 * 60 * 1000;
66
-
67
- // In-memory rate limiter. Per-user sliding window over 1 hour.
68
- // HF Spaces typically restart every deploy, so the limiter
69
- // implicitly resets then; that's acceptable for v1. If we ever
70
- // need durability or multi-replica fairness, swap the Map for a
71
- // shared KV (Redis, Upstash, ...) without changing the rest of
72
- // the module.
73
- const rateLimits = new Map();
74
- const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
75
-
76
- const DEFAULT_RATE_LIMIT_PER_HOUR = 60;
77
- // Match what the Reachy Mini mobile shell is built against today
78
- // (`features/conversation/engine/settings.ts:DEFAULT_MODEL` and
79
- // `DEFAULT_VOICE`). Bumping these requires coordinating with the
80
- // mobile client because the GA WebRTC handshake (`/v1/realtime/calls`)
81
- // negotiates the session shape against this same configuration.
82
- const DEFAULT_REALTIME_MODEL = 'gpt-realtime-2';
83
- const DEFAULT_REALTIME_VOICE = 'cedar';
84
-
85
- function getRateLimitMax() {
86
- const raw = process.env.OPENAI_EPHEMERAL_RATE_LIMIT_PER_HOUR;
87
- const parsed = raw ? Number.parseInt(raw, 10) : NaN;
88
- return Number.isFinite(parsed) && parsed > 0
89
- ? parsed
90
- : DEFAULT_RATE_LIMIT_PER_HOUR;
91
- }
92
-
93
- function getDefaultModel() {
94
- const raw = process.env.OPENAI_REALTIME_MODEL;
95
- return raw && raw.trim() !== '' ? raw.trim() : DEFAULT_REALTIME_MODEL;
96
- }
97
-
98
- class HttpError extends Error {
99
- constructor(status, message) {
100
- super(message);
101
- this.status = status;
102
- }
103
- }
104
-
105
- /**
106
- * Resolve `Bearer <token>` -> the HF user object, with a 5-minute
107
- * cache. Throws `HttpError(401)` on a rejected token so the route
108
- * can surface a clean 401 to the caller.
109
- */
110
- async function verifyHfToken(token) {
111
- const now = Date.now();
112
- const cached = whoamiCache.get(token);
113
- if (cached && cached.exp > now) return cached.user;
114
-
115
- let r;
116
- try {
117
- r = await fetch(HF_WHOAMI_URL, {
118
- headers: { Authorization: `Bearer ${token}` },
119
- });
120
- } catch (err) {
121
- // Network blip: surface a 502 so the client can retry. We
122
- // explicitly do NOT cache a failure, so a transient outage
123
- // doesn't lock the user out for 5 minutes.
124
- throw new HttpError(502, `hf whoami network error: ${err.message}`);
125
- }
126
-
127
- if (r.status === 401 || r.status === 403) {
128
- throw new HttpError(401, 'invalid hf token');
129
- }
130
- if (!r.ok) {
131
- throw new HttpError(502, `hf whoami returned ${r.status}`);
132
- }
133
-
134
- const user = await r.json().catch(() => null);
135
- // `name` is the canonical HF identifier across users + orgs;
136
- // `id` is the numeric backstop in case the schema ever shifts.
137
- if (
138
- !user ||
139
- (typeof user.name !== 'string' && typeof user.id !== 'string')
140
- ) {
141
- throw new HttpError(502, 'hf whoami returned malformed user');
142
- }
143
-
144
- whoamiCache.set(token, { user, exp: now + WHOAMI_TTL_MS });
145
- return user;
146
- }
147
-
148
- /**
149
- * Enforce the sliding-window rate limit for `userId`. Throws
150
- * `HttpError(429)` on overflow. Mutates `rateLimits` to record
151
- * the current mint timestamp.
152
- */
153
- function checkRateLimit(userId) {
154
- const now = Date.now();
155
- const windowStart = now - RATE_LIMIT_WINDOW_MS;
156
- const history = rateLimits.get(userId) || [];
157
- const recent = history.filter((t) => t > windowStart);
158
- if (recent.length >= getRateLimitMax()) {
159
- throw new HttpError(
160
- 429,
161
- `rate limit exceeded (${getRateLimitMax()}/hour)`,
162
- );
163
- }
164
- recent.push(now);
165
- rateLimits.set(userId, recent);
166
- }
167
-
168
- /**
169
- * Express handler for `POST /api/openai/ephemeral`. Stateless from
170
- * the caller's perspective: the client posts an HF Bearer token,
171
- * gets back the OpenAI Realtime session payload, uses it once.
172
- */
173
- export async function mintEphemeralKeyHandler(req, res) {
174
- try {
175
- if (!process.env.OPENAI_API_KEY) {
176
- return res
177
- .status(503)
178
- .json({ error: 'OPENAI_API_KEY not configured on this Space' });
179
- }
180
-
181
- const auth = req.headers.authorization || '';
182
- const match = auth.match(/^Bearer\s+(.+)$/i);
183
- if (!match) {
184
- return res.status(401).json({ error: 'missing bearer token' });
185
- }
186
- const hfToken = match[1].trim();
187
- if (!hfToken) {
188
- return res.status(401).json({ error: 'empty bearer token' });
189
- }
190
-
191
- const user = await verifyHfToken(hfToken);
192
- const userId = user.name || String(user.id);
193
- checkRateLimit(userId);
194
-
195
- // Caller may override model/voice for A/B tests, but the
196
- // defaults match what the mobile shell is built against. We
197
- // intentionally do NOT forward arbitrary fields from the
198
- // request body to OpenAI: only the two we validated.
199
- const body = req.body || {};
200
- const model =
201
- typeof body.model === 'string' && body.model.trim() !== ''
202
- ? body.model.trim()
203
- : getDefaultModel();
204
- const voice =
205
- typeof body.voice === 'string' && body.voice.trim() !== ''
206
- ? body.voice.trim()
207
- : DEFAULT_REALTIME_VOICE;
208
-
209
- // GA body shape: the session config sits under `session`, with
210
- // a required `type` discriminator (`"realtime"` for the voice
211
- // pipeline, `"transcription"` for transcription-only). The
212
- // mobile shell only needs `realtime`.
213
- const openaiBody = {
214
- session: {
215
- type: 'realtime',
216
- model,
217
- // The GA schema expects `audio.output.voice`. We mirror
218
- // the minimal shape: clients can still issue
219
- // `session.update` events over the data channel after
220
- // connect to tweak modalities, tools, instructions, etc.
221
- audio: {
222
- output: { voice },
223
- },
224
- },
225
- };
226
-
227
- let openaiRes;
228
- try {
229
- openaiRes = await fetch(OPENAI_CLIENT_SECRETS_URL, {
230
- method: 'POST',
231
- headers: {
232
- Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
233
- 'Content-Type': 'application/json',
234
- },
235
- body: JSON.stringify(openaiBody),
236
- });
237
- } catch (err) {
238
- console.error('[openai] mint network error:', err);
239
- return res.status(502).json({ error: 'openai network error' });
240
- }
241
-
242
- if (!openaiRes.ok) {
243
- const text = await openaiRes.text().catch(() => '');
244
- console.error(
245
- `[openai] mint failed for ${userId}: ${openaiRes.status} ${text}`,
246
- );
247
- return res.status(502).json({
248
- error: 'openai mint failed',
249
- upstreamStatus: openaiRes.status,
250
- });
251
- }
252
-
253
- const payload = await openaiRes.json();
254
- // We log the user id and the chosen model but NEVER the
255
- // client_secret. The secret stays on the wire to the
256
- // requesting client only.
257
- console.log(
258
- `[openai] minted ephemeral for ${userId} (model=${model}, voice=${voice})`,
259
- );
260
- return res.json(payload);
261
- } catch (err) {
262
- if (err instanceof HttpError) {
263
- return res.status(err.status).json({ error: err.message });
264
- }
265
- console.error('[openai] unexpected mint error:', err);
266
- return res.status(500).json({ error: 'internal error' });
267
- }
268
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
@@ -105,17 +86,7 @@ export function AppsProvider({ children }) {
105
  if (response.ok) {
106
  const data = await response.json();
107
  allApps = data.apps;
108
- // Cache age moved from response body to the standard
109
- // `Age` HTTP header (RFC 7234 §5.1) so the body stays
110
- // byte-stable across same-cache-window requests and
111
- // ETag-based 304s work. `headers.get('age')` is null
112
- // on same-origin if the server didn't set it (older
113
- // deploy of the website Space): degrade gracefully to
114
- // a `?` rather than poisoning the log with `null`.
115
- const age = response.headers.get('age');
116
- console.log(
117
- `[AppsContext] Fetched ${allApps.length} apps from server cache (age: ${age ?? '?'}s)`,
118
- );
119
  } else {
120
  throw new Error('Server API not available');
121
  }
 
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;
 
86
  if (response.ok) {
87
  const data = await response.json();
88
  allApps = data.apps;
89
+ console.log(`[AppsContext] Fetched ${allApps.length} apps from server cache (age: ${data.cacheAge}s)`);
 
 
 
 
 
 
 
 
 
 
90
  } else {
91
  throw new Error('Server API not available');
92
  }
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
@@ -10,7 +10,6 @@ import {
10
  CardContent,
11
  Chip,
12
  Stack,
13
- Link,
14
  Accordion,
15
  AccordionSummary,
16
  AccordionDetails,
@@ -26,7 +25,6 @@ import CheckIcon from '@mui/icons-material/Check';
26
  import CloseIcon from '@mui/icons-material/Close';
27
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
28
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
29
- import PublicIcon from '@mui/icons-material/Public';
30
  import LocalShippingIcon from '@mui/icons-material/LocalShipping';
31
  import InventoryIcon from '@mui/icons-material/Inventory';
32
  import SupportAgentIcon from '@mui/icons-material/SupportAgent';
@@ -40,24 +38,22 @@ const products = {
40
  wireless: {
41
  name: 'Reachy Mini',
42
  tagline: 'The complete experience',
43
- price: 499,
44
  badge: 'Wireless',
45
  badgeColor: '#0ea5e9',
46
- description: 'Self-contained robot with on-board compute. Works wirelessly or wired, perfect for standalone projects and demos. <strong>Lead time: up to 90 days after purchase</strong> (many orders ship sooner).',
47
- buyLink: 'https://store.pollen-robotics.com/products/reachy-mini-wireless-version',
48
- seeedLink: 'https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html',
49
  image: '/assets/reachy-wireless.png',
50
  featured: true,
51
  },
52
  lite: {
53
  name: 'Reachy Mini Lite',
54
  tagline: 'Perfect to get started',
55
- price: 399,
56
  badge: 'Lite',
57
  badgeColor: '#f59e0b',
58
- description: 'Connect to your computer via USB. Same expressive robot, powered by your machine. Ideal for development and learning. <strong>Lead time: up to 90 days after purchase</strong> (many orders ship sooner).',
59
- buyLink: 'https://store.pollen-robotics.com/products/reachy-mini-lite-version',
60
- seeedLink: 'https://www.seeedstudio.com/Reachy-Mini-Lite-Kit-p-6702.html',
61
  image: '/assets/reachy-lite.png',
62
  featured: false,
63
  },
@@ -72,7 +68,7 @@ const comparisonFeatures = [
72
  { name: 'Camera', wireless: 'Wide angle', lite: 'Wide angle' },
73
  { name: 'Microphones', wireless: '4 microphones array', lite: '4 microphones array' },
74
  { name: 'Speaker', wireless: '5W speaker', lite: '5W speaker' },
75
- { name: 'On-board Compute', wireless: 'Raspberry Pi CM 4 (16GB storage)', lite: false },
76
  { name: 'Accelerometer', wireless: 'Built-in IMU', lite: false },
77
  { name: 'Wi-Fi Connectivity', wireless: 'Wi-Fi', lite: false },
78
  { name: 'Standalone Mode', wireless: true, lite: false },
@@ -94,7 +90,7 @@ const boxContents = [
94
  const faqItems = [
95
  {
96
  question: 'What is the difference between Wireless and Lite?',
97
- 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.',
98
  },
99
  {
100
  question: 'How long does assembly take?',
@@ -102,7 +98,7 @@ const faqItems = [
102
  },
103
  {
104
  question: 'What about customs and import taxes?',
105
- answer: 'All orders ship DAP (Delivered At Place). Shipping costs and any local import duties, taxes, or customs fees are the responsibility of the buyer and are not included in the product price.',
106
  },
107
  {
108
  question: 'Can I upgrade from Lite to Wireless later?',
@@ -342,7 +338,7 @@ function ProductCardsSection() {
342
  <Stack spacing={1} sx={{ mb: 3 }}>
343
  {key === 'wireless' ? (
344
  <>
345
- <FeatureRow icon="✓" text="On-board Raspberry Pi CM 4" highlight />
346
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
347
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
348
  </>
@@ -371,66 +367,11 @@ function ProductCardsSection() {
371
  >
372
  Buy {product.badge} — ${product.price}
373
  </Button>
374
-
375
- <Typography
376
- variant="body2"
377
- sx={{
378
- mt: 2,
379
- textAlign: 'center',
380
- fontSize: 13,
381
- fontWeight: 600,
382
- color: 'text.secondary',
383
- }}
384
- >
385
- Outside EU/UK & US/Canada?
386
- </Typography>
387
- <Button
388
- variant="outlined"
389
- size="medium"
390
- fullWidth
391
- href={product.seeedLink}
392
- target="_blank"
393
- rel="noopener noreferrer"
394
- startIcon={<PublicIcon />}
395
- sx={{ mt: 1 }}
396
- >
397
- Order via Seeed Studio
398
- </Button>
399
  </CardContent>
400
  </Card>
401
  </Grid>
402
  ))}
403
  </Grid>
404
-
405
- {/* Lead time & shipping notice */}
406
- <Box sx={{ textAlign: 'center', mt: 5, mb: 6 }}>
407
- <Typography
408
- variant="body1"
409
- sx={{ fontWeight: 600, color: 'text.primary' }}
410
- >
411
- Lead time: up to 90 days after purchase (many orders ship sooner)
412
- </Typography>
413
- <Typography
414
- variant="body2"
415
- color="text.secondary"
416
- sx={{ maxWidth: 600, mx: 'auto', lineHeight: 1.7, mt: 1 }}
417
- >
418
- <strong>Shipping and import duties:</strong> all orders ship DAP (Delivered At Place).
419
- <br />
420
- Shipping costs and any local import duties, taxes, or customs fees are the responsibility of the buyer and are not included in the product price.
421
- <br />
422
- Our store ships to EU/UK and US/Canada. For other countries, order
423
- from our official distributor{' '}
424
- <Link
425
- href="https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html"
426
- target="_blank"
427
- rel="noopener noreferrer"
428
- >
429
- Seeed Studio
430
- </Link>
431
- .
432
- </Typography>
433
- </Box>
434
  </Container>
435
  );
436
  }
@@ -485,13 +426,13 @@ function ComparisonSection() {
485
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
486
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
487
  <Chip label="Wireless" size="small" sx={{ bgcolor: '#0ea5e920', color: '#0ea5e9', fontWeight: 600 }} />
488
- <Typography fontWeight={700}>$499</Typography>
489
  </Stack>
490
  </TableCell>
491
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
492
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
493
  <Chip label="Lite" size="small" sx={{ bgcolor: '#f59e0b20', color: '#f59e0b', fontWeight: 600 }} />
494
- <Typography fontWeight={700}>$399</Typography>
495
  </Stack>
496
  </TableCell>
497
  </TableRow>
@@ -682,7 +623,7 @@ function FinalCTASection() {
682
  '&:hover': { bgcolor: '#e68600' },
683
  }}
684
  >
685
- Buy Wireless — $499
686
  </Button>
687
  <Button
688
  variant="outlined"
@@ -700,7 +641,7 @@ function FinalCTASection() {
700
  },
701
  }}
702
  >
703
- Buy Lite — $399
704
  </Button>
705
  </Stack>
706
  </Container>
 
10
  CardContent,
11
  Chip,
12
  Stack,
 
13
  Accordion,
14
  AccordionSummary,
15
  AccordionDetails,
 
25
  import CloseIcon from '@mui/icons-material/Close';
26
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
27
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
 
28
  import LocalShippingIcon from '@mui/icons-material/LocalShipping';
29
  import InventoryIcon from '@mui/icons-material/Inventory';
30
  import SupportAgentIcon from '@mui/icons-material/SupportAgent';
 
38
  wireless: {
39
  name: 'Reachy Mini',
40
  tagline: 'The complete experience',
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,
48
  },
49
  lite: {
50
  name: 'Reachy Mini Lite',
51
  tagline: 'Perfect to get started',
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,
59
  },
 
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
  </>
 
367
  >
368
  Buy {product.badge} — ${product.price}
369
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  </CardContent>
371
  </Card>
372
  </Grid>
373
  ))}
374
  </Grid>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  </Container>
376
  );
377
  }
 
426
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
427
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
428
  <Chip label="Wireless" size="small" sx={{ bgcolor: '#0ea5e920', color: '#0ea5e9', fontWeight: 600 }} />
429
+ <Typography fontWeight={700}>$449</Typography>
430
  </Stack>
431
  </TableCell>
432
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
433
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
434
  <Chip label="Lite" size="small" sx={{ bgcolor: '#f59e0b20', color: '#f59e0b', fontWeight: 600 }} />
435
+ <Typography fontWeight={700}>$299</Typography>
436
  </Stack>
437
  </TableCell>
438
  </TableRow>
 
623
  '&:hover': { bgcolor: '#e68600' },
624
  }}
625
  >
626
+ Buy Wireless — $449
627
  </Button>
628
  <Button
629
  variant="outlined"
 
641
  },
642
  }}
643
  >
644
+ Buy Lite — $299
645
  </Button>
646
  </Stack>
647
  </Container>
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
@@ -11,7 +11,6 @@ import {
11
  } from "@mui/material";
12
  import { useSpring, animated } from "@react-spring/web";
13
  import OpenInNewIcon from "@mui/icons-material/OpenInNew";
14
- import PublicIcon from "@mui/icons-material/Public";
15
  import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
16
 
17
  import Layout from "../components/Layout";
@@ -658,7 +657,7 @@ function ProductsSection() {
658
  WebkitTextFillColor: "transparent",
659
  }}
660
  >
661
- $499
662
  </Typography>
663
 
664
  <Stack
@@ -666,7 +665,7 @@ function ProductsSection() {
666
  sx={{ mb: 4, textAlign: "left", maxWidth: 280, mx: "auto" }}
667
  >
668
  {[
669
- "Raspberry Pi CM 4 on-board",
670
  "Wi-Fi + USB",
671
  "Camera, 4 mics, speaker",
672
  "Accelerometer",
@@ -684,30 +683,12 @@ function ProductsSection() {
684
  variant="contained"
685
  size="large"
686
  fullWidth
687
- href="https://store.pollen-robotics.com/products/reachy-mini-wireless-version"
688
  target="_blank"
689
  sx={{ py: 1.5 }}
690
  >
691
  Order Now
692
  </Button>
693
-
694
- <Typography
695
- sx={{ mt: 2, fontSize: 13, fontWeight: 600, color: "text.secondary" }}
696
- >
697
- Outside EU/UK & US/Canada?
698
- </Typography>
699
- <Button
700
- variant="outlined"
701
- size="medium"
702
- fullWidth
703
- href="https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html"
704
- target="_blank"
705
- rel="noopener noreferrer"
706
- startIcon={<PublicIcon />}
707
- sx={{ mt: 1 }}
708
- >
709
- Order via Seeed Studio
710
- </Button>
711
  </Box>
712
  </Grid>
713
 
@@ -737,7 +718,7 @@ function ProductsSection() {
737
  color: "text.primary",
738
  }}
739
  >
740
- $399
741
  </Typography>
742
 
743
  <Stack
@@ -763,30 +744,12 @@ function ProductsSection() {
763
  variant="outlined"
764
  size="large"
765
  fullWidth
766
- href="https://store.pollen-robotics.com/products/reachy-mini-lite-version"
767
  target="_blank"
768
  sx={{ py: 1.5 }}
769
  >
770
  Order Now
771
  </Button>
772
-
773
- <Typography
774
- sx={{ mt: 2, fontSize: 13, fontWeight: 600, color: "text.secondary" }}
775
- >
776
- Outside EU/UK & US/Canada?
777
- </Typography>
778
- <Button
779
- variant="outlined"
780
- size="medium"
781
- fullWidth
782
- href="https://www.seeedstudio.com/Reachy-Mini-Lite-Kit-p-6702.html"
783
- target="_blank"
784
- rel="noopener noreferrer"
785
- startIcon={<PublicIcon />}
786
- sx={{ mt: 1 }}
787
- >
788
- Order via Seeed Studio
789
- </Button>
790
  </Box>
791
  </Grid>
792
  </Grid>
@@ -800,32 +763,7 @@ function ProductsSection() {
800
  fontWeight: 600,
801
  }}
802
  >
803
- Lead time: up to 90 days after purchase (many orders ship sooner)
804
- </Typography>
805
- <Typography
806
- variant="body2"
807
- sx={{
808
- color: "text.secondary",
809
- mt: 1,
810
- maxWidth: 520,
811
- mx: "auto",
812
- lineHeight: 1.7,
813
- }}
814
- >
815
- <strong>Shipping and import duties:</strong> all orders ship DAP (Delivered At Place).
816
- <br />
817
- Shipping costs and any local import duties, taxes, or customs fees are the responsibility of the buyer and are not included in the product price.
818
- <br />
819
- Our store ships to EU/UK and US/Canada. For other countries, order
820
- from our official distributor{" "}
821
- <Link
822
- href="https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html"
823
- target="_blank"
824
- rel="noopener noreferrer"
825
- >
826
- Seeed Studio
827
- </Link>
828
- .
829
  </Typography>
830
  </Box>
831
 
 
11
  } from "@mui/material";
12
  import { useSpring, animated } from "@react-spring/web";
13
  import OpenInNewIcon from "@mui/icons-material/OpenInNew";
 
14
  import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
15
 
16
  import Layout from "../components/Layout";
 
657
  WebkitTextFillColor: "transparent",
658
  }}
659
  >
660
+ $449
661
  </Typography>
662
 
663
  <Stack
 
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",
 
683
  variant="contained"
684
  size="large"
685
  fullWidth
686
+ href="https://buy.stripe.com/9B65kFfFlaKFbY34W873G03"
687
  target="_blank"
688
  sx={{ py: 1.5 }}
689
  >
690
  Order Now
691
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  </Box>
693
  </Grid>
694
 
 
718
  color: "text.primary",
719
  }}
720
  >
721
+ $299
722
  </Typography>
723
 
724
  <Stack
 
744
  variant="outlined"
745
  size="large"
746
  fullWidth
747
+ href="https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02"
748
  target="_blank"
749
  sx={{ py: 1.5 }}
750
  >
751
  Order Now
752
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  </Box>
754
  </Grid>
755
  </Grid>
 
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"
@@ -1031,14 +1012,14 @@ browserslist@^4.24.0:
1031
  node-releases "^2.0.27"
1032
  update-browserslist-db "^1.2.0"
1033
 
1034
- bytes@3.1.2, 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"
@@ -1124,26 +1098,6 @@ comma-separated-tokens@^2.0.0:
1124
  resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz"
1125
  integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
1126
 
1127
- compressible@~2.0.18:
1128
- version "2.0.18"
1129
- resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
1130
- integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
1131
- dependencies:
1132
- mime-db ">= 1.43.0 < 2"
1133
-
1134
- compression@^1.8.1:
1135
- version "1.8.1"
1136
- resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79"
1137
- integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==
1138
- dependencies:
1139
- bytes "3.1.2"
1140
- compressible "~2.0.18"
1141
- debug "2.6.9"
1142
- negotiator "~0.6.4"
1143
- on-headers "~1.1.0"
1144
- safe-buffer "5.2.1"
1145
- vary "~1.1.2"
1146
-
1147
  concat-map@0.0.1:
1148
  version "0.0.1"
1149
  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
@@ -1151,14 +1105,14 @@ concat-map@0.0.1:
1151
 
1152
  content-disposition@~0.5.4:
1153
  version "0.5.4"
1154
- resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
1155
  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
1156
  dependencies:
1157
  safe-buffer "5.2.1"
1158
 
1159
  content-type@~1.0.4, content-type@~1.0.5:
1160
  version "1.0.5"
1161
- resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz"
1162
  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
1163
 
1164
  convert-source-map@^1.5.0:
@@ -1173,7 +1127,7 @@ convert-source-map@^2.0.0:
1173
 
1174
  cookie-signature@~1.0.6:
1175
  version "1.0.7"
1176
- resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz"
1177
  integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==
1178
 
1179
  cookie@^1.0.1:
@@ -1183,7 +1137,7 @@ cookie@^1.0.1:
1183
 
1184
  cookie@~0.7.1:
1185
  version "0.7.2"
1186
- resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz"
1187
  integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
1188
 
1189
  cosmiconfig@^7.0.0:
@@ -1213,7 +1167,7 @@ csstype@^3.0.2, csstype@^3.1.3, csstype@^3.2.2:
1213
 
1214
  debug@2.6.9:
1215
  version "2.6.9"
1216
- resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
1217
  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
1218
  dependencies:
1219
  ms "2.0.0"
@@ -1239,7 +1193,7 @@ deep-is@^0.1.3:
1239
 
1240
  depd@2.0.0, depd@~2.0.0:
1241
  version "2.0.0"
1242
- resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz"
1243
  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
1244
 
1245
  dequal@^2.0.0:
@@ -1249,7 +1203,7 @@ dequal@^2.0.0:
1249
 
1250
  destroy@1.2.0, destroy@~1.2.0:
1251
  version "1.2.0"
1252
- resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz"
1253
  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
1254
 
1255
  devlop@^1.0.0, devlop@^1.1.0:
@@ -1269,7 +1223,7 @@ dom-helpers@^5.0.1:
1269
 
1270
  dunder-proto@^1.0.1:
1271
  version "1.0.1"
1272
- resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"
1273
  integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
1274
  dependencies:
1275
  call-bind-apply-helpers "^1.0.1"
@@ -1278,7 +1232,7 @@ dunder-proto@^1.0.1:
1278
 
1279
  ee-first@1.1.1:
1280
  version "1.1.1"
1281
- resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
1282
  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
1283
 
1284
  electron-to-chromium@^1.5.263:
@@ -1286,14 +1240,9 @@ electron-to-chromium@^1.5.263:
1286
  resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz"
1287
  integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
1288
 
1289
- emoji-regex@^8.0.0:
1290
- version "8.0.0"
1291
- resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
1292
- integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
1293
-
1294
  encodeurl@~2.0.0:
1295
  version "2.0.0"
1296
- resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
1297
  integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
1298
 
1299
  error-ex@^1.3.1:
@@ -1305,17 +1254,17 @@ error-ex@^1.3.1:
1305
 
1306
  es-define-property@^1.0.1:
1307
  version "1.0.1"
1308
- resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz"
1309
  integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
1310
 
1311
  es-errors@^1.3.0:
1312
  version "1.3.0"
1313
- resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz"
1314
  integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
1315
 
1316
  es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
1317
  version "1.1.1"
1318
- resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz"
1319
  integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
1320
  dependencies:
1321
  es-errors "^1.3.0"
@@ -1359,7 +1308,7 @@ escalade@^3.2.0:
1359
 
1360
  escape-html@~1.0.3:
1361
  version "1.0.3"
1362
- resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
1363
  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
1364
 
1365
  escape-string-regexp@^4.0.0:
@@ -1486,12 +1435,12 @@ esutils@^2.0.2:
1486
 
1487
  etag@~1.8.1:
1488
  version "1.8.1"
1489
- resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"
1490
  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
1491
 
1492
  express@^4.21.2:
1493
  version "4.22.1"
1494
- resolved "https://registry.npmjs.org/express/-/express-4.22.1.tgz"
1495
  integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==
1496
  dependencies:
1497
  accepts "~1.3.8"
@@ -1560,7 +1509,7 @@ file-entry-cache@^8.0.0:
1560
 
1561
  finalhandler@~1.3.1:
1562
  version "1.3.2"
1563
- resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz"
1564
  integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==
1565
  dependencies:
1566
  debug "2.6.9"
@@ -1599,7 +1548,7 @@ flatted@^3.2.9:
1599
 
1600
  forwarded@0.2.0:
1601
  version "0.2.0"
1602
- resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
1603
  integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
1604
 
1605
  framer-motion@^12.23.26:
@@ -1613,7 +1562,7 @@ framer-motion@^12.23.26:
1613
 
1614
  fresh@~0.5.2:
1615
  version "0.5.2"
1616
- resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz"
1617
  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
1618
 
1619
  fsevents@~2.3.2, fsevents@~2.3.3:
@@ -1626,11 +1575,6 @@ function-bind@^1.1.2:
1626
  resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
1627
  integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
1628
 
1629
- fuse.js@^7.1.0:
1630
- version "7.1.0"
1631
- resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.1.0.tgz#306228b4befeee11e05b027087c2744158527d09"
1632
- integrity sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==
1633
-
1634
  gensync@^1.0.0-beta.2:
1635
  version "1.0.0-beta.2"
1636
  resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
@@ -1638,7 +1582,7 @@ gensync@^1.0.0-beta.2:
1638
 
1639
  get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
1640
  version "1.3.0"
1641
- resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
1642
  integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
1643
  dependencies:
1644
  call-bind-apply-helpers "^1.0.2"
@@ -1654,7 +1598,7 @@ get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
1654
 
1655
  get-proto@^1.0.1:
1656
  version "1.0.1"
1657
- resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz"
1658
  integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
1659
  dependencies:
1660
  dunder-proto "^1.0.1"
@@ -1679,7 +1623,7 @@ globals@^16.5.0:
1679
 
1680
  gopd@^1.2.0:
1681
  version "1.2.0"
1682
- resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz"
1683
  integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
1684
 
1685
  has-flag@^4.0.0:
@@ -1689,7 +1633,7 @@ has-flag@^4.0.0:
1689
 
1690
  has-symbols@^1.1.0:
1691
  version "1.1.0"
1692
- resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz"
1693
  integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
1694
 
1695
  hasown@^2.0.2:
@@ -1775,7 +1719,7 @@ html-url-attributes@^3.0.0:
1775
 
1776
  http-errors@~2.0.0, http-errors@~2.0.1:
1777
  version "2.0.1"
1778
- resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz"
1779
  integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
1780
  dependencies:
1781
  depd "~2.0.0"
@@ -1786,7 +1730,7 @@ http-errors@~2.0.0, http-errors@~2.0.1:
1786
 
1787
  iconv-lite@~0.4.24:
1788
  version "0.4.24"
1789
- resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz"
1790
  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
1791
  dependencies:
1792
  safer-buffer ">= 2.1.2 < 3"
@@ -1811,7 +1755,7 @@ imurmurhash@^0.1.4:
1811
 
1812
  inherits@~2.0.4:
1813
  version "2.0.4"
1814
- resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
1815
  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
1816
 
1817
  inline-style-parser@0.2.7:
@@ -1821,7 +1765,7 @@ inline-style-parser@0.2.7:
1821
 
1822
  ipaddr.js@1.9.1:
1823
  version "1.9.1"
1824
- resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
1825
  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
1826
 
1827
  is-alphabetical@^2.0.0:
@@ -1859,11 +1803,6 @@ is-extglob@^2.1.1:
1859
  resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
1860
  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
1861
 
1862
- is-fullwidth-code-point@^3.0.0:
1863
- version "3.0.0"
1864
- resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz"
1865
- integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
1866
-
1867
  is-glob@^4.0.0, is-glob@^4.0.3:
1868
  version "4.0.3"
1869
  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
@@ -1995,7 +1934,7 @@ markdown-table@^3.0.0:
1995
 
1996
  math-intrinsics@^1.1.0:
1997
  version "1.1.0"
1998
- resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
1999
  integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
2000
 
2001
  mdast-util-find-and-replace@^3.0.0:
@@ -2180,17 +2119,17 @@ mdast-util-to-string@^4.0.0:
2180
 
2181
  media-typer@0.3.0:
2182
  version "0.3.0"
2183
- resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
2184
  integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
2185
 
2186
  merge-descriptors@1.0.3:
2187
  version "1.0.3"
2188
- resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz"
2189
  integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
2190
 
2191
  methods@~1.1.2:
2192
  version "1.1.2"
2193
- resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
2194
  integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
2195
 
2196
  micromark-core-commonmark@^2.0.0:
@@ -2468,24 +2407,19 @@ micromark@^4.0.0:
2468
 
2469
  mime-db@1.52.0:
2470
  version "1.52.0"
2471
- resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
2472
  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
2473
 
2474
- "mime-db@>= 1.43.0 < 2":
2475
- version "1.54.0"
2476
- resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
2477
- integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
2478
-
2479
  mime-types@~2.1.24, mime-types@~2.1.34:
2480
  version "2.1.35"
2481
- resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
2482
  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
2483
  dependencies:
2484
  mime-db "1.52.0"
2485
 
2486
  mime@1.6.0:
2487
  version "1.6.0"
2488
- resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
2489
  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
2490
 
2491
  minimatch@^3.1.2:
@@ -2509,12 +2443,12 @@ motion-utils@^12.23.6:
2509
 
2510
  ms@2.0.0:
2511
  version "2.0.0"
2512
- resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
2513
  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
2514
 
2515
  ms@2.1.3, ms@^2.1.3:
2516
  version "2.1.3"
2517
- resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
2518
  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
2519
 
2520
  nanoid@^3.3.11:
@@ -2529,14 +2463,9 @@ natural-compare@^1.4.0:
2529
 
2530
  negotiator@0.6.3:
2531
  version "0.6.3"
2532
- resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
2533
  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
2534
 
2535
- negotiator@~0.6.4:
2536
- version "0.6.4"
2537
- resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7"
2538
- integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==
2539
-
2540
  node-releases@^2.0.27:
2541
  version "2.0.27"
2542
  resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
@@ -2549,21 +2478,16 @@ object-assign@^4.1.1:
2549
 
2550
  object-inspect@^1.13.3:
2551
  version "1.13.4"
2552
- resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz"
2553
  integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
2554
 
2555
  on-finished@~2.4.1:
2556
  version "2.4.1"
2557
- resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"
2558
  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
2559
  dependencies:
2560
  ee-first "1.1.1"
2561
 
2562
- on-headers@~1.1.0:
2563
- version "1.1.0"
2564
- resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65"
2565
- integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==
2566
-
2567
  optionator@^0.9.3:
2568
  version "0.9.4"
2569
  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"
@@ -2622,7 +2546,7 @@ parse-json@^5.0.0:
2622
 
2623
  parseurl@~1.3.3:
2624
  version "1.3.3"
2625
- resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz"
2626
  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
2627
 
2628
  path-exists@^4.0.0:
@@ -2642,7 +2566,7 @@ path-parse@^1.0.7:
2642
 
2643
  path-to-regexp@~0.1.12:
2644
  version "0.1.12"
2645
- resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz"
2646
  integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==
2647
 
2648
  path-type@^4.0.0:
@@ -2690,7 +2614,7 @@ property-information@^7.0.0:
2690
 
2691
  proxy-addr@~2.0.7:
2692
  version "2.0.7"
2693
- resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz"
2694
  integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
2695
  dependencies:
2696
  forwarded "0.2.0"
@@ -2703,19 +2627,19 @@ punycode@^2.1.0:
2703
 
2704
  qs@~6.14.0:
2705
  version "6.14.1"
2706
- resolved "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz"
2707
  integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==
2708
  dependencies:
2709
  side-channel "^1.1.0"
2710
 
2711
  range-parser@~1.2.1:
2712
  version "1.2.1"
2713
- resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz"
2714
  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
2715
 
2716
  raw-body@~2.5.3:
2717
  version "2.5.3"
2718
- resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz"
2719
  integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==
2720
  dependencies:
2721
  bytes "~3.1.2"
@@ -2892,12 +2816,12 @@ rollup@^4.43.0:
2892
 
2893
  safe-buffer@5.2.1:
2894
  version "5.2.1"
2895
- resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
2896
  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
2897
 
2898
  "safer-buffer@>= 2.1.2 < 3":
2899
  version "2.1.2"
2900
- resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
2901
  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
2902
 
2903
  scheduler@^0.27.0:
@@ -2912,7 +2836,7 @@ semver@^6.3.1:
2912
 
2913
  send@~0.19.0, send@~0.19.1:
2914
  version "0.19.2"
2915
- resolved "https://registry.npmjs.org/send/-/send-0.19.2.tgz"
2916
  integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==
2917
  dependencies:
2918
  debug "2.6.9"
@@ -2931,7 +2855,7 @@ send@~0.19.0, send@~0.19.1:
2931
 
2932
  serve-static@~1.16.2:
2933
  version "1.16.3"
2934
- resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz"
2935
  integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==
2936
  dependencies:
2937
  encodeurl "~2.0.0"
@@ -2946,7 +2870,7 @@ set-cookie-parser@^2.6.0:
2946
 
2947
  setprototypeof@1.2.0, setprototypeof@~1.2.0:
2948
  version "1.2.0"
2949
- resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz"
2950
  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
2951
 
2952
  shebang-command@^2.0.0:
@@ -2963,7 +2887,7 @@ shebang-regex@^3.0.0:
2963
 
2964
  side-channel-list@^1.0.0:
2965
  version "1.0.0"
2966
- resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz"
2967
  integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
2968
  dependencies:
2969
  es-errors "^1.3.0"
@@ -2971,7 +2895,7 @@ side-channel-list@^1.0.0:
2971
 
2972
  side-channel-map@^1.0.1:
2973
  version "1.0.1"
2974
- resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz"
2975
  integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
2976
  dependencies:
2977
  call-bound "^1.0.2"
@@ -2981,7 +2905,7 @@ side-channel-map@^1.0.1:
2981
 
2982
  side-channel-weakmap@^1.0.2:
2983
  version "1.0.2"
2984
- resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz"
2985
  integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
2986
  dependencies:
2987
  call-bound "^1.0.2"
@@ -2992,7 +2916,7 @@ side-channel-weakmap@^1.0.2:
2992
 
2993
  side-channel@^1.1.0:
2994
  version "1.1.0"
2995
- resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz"
2996
  integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
2997
  dependencies:
2998
  es-errors "^1.3.0"
@@ -3018,18 +2942,9 @@ space-separated-tokens@^2.0.0:
3018
 
3019
  statuses@~2.0.1, statuses@~2.0.2:
3020
  version "2.0.2"
3021
- resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz"
3022
  integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
3023
 
3024
- string-width@^4.2.3:
3025
- version "4.2.3"
3026
- resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
3027
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
3028
- dependencies:
3029
- emoji-regex "^8.0.0"
3030
- is-fullwidth-code-point "^3.0.0"
3031
- strip-ansi "^6.0.1"
3032
-
3033
  stringify-entities@^4.0.0:
3034
  version "4.0.4"
3035
  resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz"
@@ -3038,13 +2953,6 @@ stringify-entities@^4.0.0:
3038
  character-entities-html4 "^2.0.0"
3039
  character-entities-legacy "^3.0.0"
3040
 
3041
- strip-ansi@^6.0.1:
3042
- version "6.0.1"
3043
- resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
3044
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
3045
- dependencies:
3046
- ansi-regex "^5.0.1"
3047
-
3048
  strip-json-comments@^3.1.1:
3049
  version "3.1.1"
3050
  resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
@@ -3091,7 +2999,7 @@ tinyglobby@^0.2.15:
3091
 
3092
  toidentifier@~1.0.1:
3093
  version "1.0.1"
3094
- resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz"
3095
  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
3096
 
3097
  trim-lines@^3.0.0:
@@ -3118,7 +3026,7 @@ type-check@^0.4.0, type-check@~0.4.0:
3118
 
3119
  type-is@~1.6.18:
3120
  version "1.6.18"
3121
- resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"
3122
  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
3123
  dependencies:
3124
  media-typer "0.3.0"
@@ -3185,7 +3093,7 @@ unist-util-visit@^5.0.0:
3185
 
3186
  unpipe@~1.0.0:
3187
  version "1.0.0"
3188
- resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
3189
  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
3190
 
3191
  update-browserslist-db@^1.2.0:
@@ -3205,12 +3113,12 @@ uri-js@^4.2.2:
3205
 
3206
  utils-merge@1.0.1:
3207
  version "1.0.1"
3208
- resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
3209
  integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
3210
 
3211
  vary@~1.1.2:
3212
  version "1.1.2"
3213
- resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
3214
  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
3215
 
3216
  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"
 
1012
  node-releases "^2.0.27"
1013
  update-browserslist-db "^1.2.0"
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"
 
1098
  resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz"
1099
  integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
1100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1101
  concat-map@0.0.1:
1102
  version "0.0.1"
1103
  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.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:
2470
  version "2.0.27"
2471
  resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
 
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"
2490
 
 
 
 
 
 
2491
  optionator@^0.9.3:
2492
  version "0.9.4"
2493
  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"
 
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: