tfrere HF Staff Cursor commited on
Commit
07a94f1
·
1 Parent(s): 4055f89

feat(api): publish iconUrl on /api/js-apps from Space siblings

Browse files

Detects a custom app glyph by scanning each Space's `siblings`
(returned by HF's `?full=true`) for `icon.svg` or `icon.png` at
the repo root, then emits an absolute `resolve/main/<file>` URL
on the app entry as `iconUrl`. Falls back to `null` when neither
candidate is present, so clients keep painting the front-matter
emoji for the long tail of apps that don't ship a custom icon.

SVG wins over PNG when both are present so authors can rely on a
single vector asset across every mount point.

Resolution lives server-side behind the existing 5-minute catalog
cache, so the lookup cost stays O(apps) per refresh - independent
of client fleet size. The HF API call already pulls `siblings` via
`full=true`; no extra network roundtrip.

Documents the convention in docs/APP_ICON_CONVENTION.md for app
authors.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (2) hide show
  1. docs/APP_ICON_CONVENTION.md +113 -0
  2. server/index.js +68 -1
docs/APP_ICON_CONVENTION.md ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
server/index.js CHANGED
@@ -53,6 +53,65 @@ const HF_SPACES_LIMIT = 1000;
53
  // from the mobile codebase down the line.
54
  const JS_APP_TAG = 'reachy_mini_js_app';
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  // Serialised LLM batch concurrency: we want at most one
57
  // categorization sweep running at a time, regardless of how many
58
  // /api/js-apps requests come in. The flag also prevents the
@@ -98,6 +157,13 @@ async function fetchAppsFromHF() {
98
  const author = spaceId.split('/')[0];
99
  const name = spaceId.split('/').pop();
100
 
 
 
 
 
 
 
 
101
  return {
102
  // Core fields (used by both website and desktop)
103
  id: spaceId,
@@ -106,7 +172,8 @@ async function fetchAppsFromHF() {
106
  url: `https://huggingface.co/spaces/${spaceId}`,
107
  source_kind: 'hf_space',
108
  isOfficial,
109
-
 
110
  // Extra metadata (desktop-compatible structure)
111
  extra: {
112
  id: spaceId,
 
53
  // from the mobile codebase down the line.
54
  const JS_APP_TAG = 'reachy_mini_js_app';
55
 
56
+ // =====================================================================
57
+ // App icon convention
58
+ // =====================================================================
59
+ //
60
+ // Convention: an app MAY commit `icon.svg` (preferred) or
61
+ // `icon.png` at the root of its HF Space repository. When present,
62
+ // the mobile shell + desktop store render it as the app glyph
63
+ // instead of the front-matter `emoji:` codepoint.
64
+ //
65
+ // We resolve the icon ONCE at indexing time (here) rather than
66
+ // probing per-client because:
67
+ // 1. We already pull `siblings` from `?full=true` (one cheap
68
+ // hub call returns the file list for every app), so the
69
+ // lookup is a pure JS filter, no extra network.
70
+ // 2. Clients see a single field (`iconUrl`) in the payload and
71
+ // don't have to know about HF resolve URLs, LFS pointers,
72
+ // or the candidate-order race ("SVG wins if both exist").
73
+ // 3. The HF API caps probes at ~hub side; doing it server-side
74
+ // keeps fanout under a 5-minute TTL behind ONE token, instead
75
+ // of every mobile shell hammering `huggingface.co/resolve/`
76
+ // to discover icons.
77
+ //
78
+ // Resolution order: `icon.svg` → `icon.png`. SVG first because the
79
+ // same asset scales cleanly across every mount point (small rail
80
+ // tile, larger pinned tile, iframe header) from a single file.
81
+ // Extra formats can be added to `ICON_CANDIDATES` if needed; order
82
+ // matters - the first match wins.
83
+ const ICON_CANDIDATES = ['icon.svg', 'icon.png'];
84
+
85
+ /**
86
+ * Look for a standard app icon file at the root of the Space.
87
+ * Returns the absolute HF resolve URL when found, `null` otherwise.
88
+ *
89
+ * We hit `resolve/main/` (not `raw/main/`) so:
90
+ * - LFS pointers follow transparently (large PNGs work).
91
+ * - `Content-Type` comes from the extension, which `<img>` needs.
92
+ * - The URL is cacheable cross-session by the browser, so
93
+ * repeated mounts of the same app glyph don't re-fetch.
94
+ */
95
+ function findIconUrl(spaceId, siblings) {
96
+ if (!spaceId || !Array.isArray(siblings)) return null;
97
+ // Build a Set of root-level filenames for O(1) candidate
98
+ // lookups. HF returns `siblings` as `[{ rfilename: "path/in/repo" }, ...]`,
99
+ // so we filter to repo-root (no slash) before testing.
100
+ const rootFiles = new Set();
101
+ for (const s of siblings) {
102
+ const name = s && typeof s.rfilename === 'string' ? s.rfilename : null;
103
+ if (!name) continue;
104
+ if (name.includes('/')) continue;
105
+ rootFiles.add(name);
106
+ }
107
+ for (const candidate of ICON_CANDIDATES) {
108
+ if (rootFiles.has(candidate)) {
109
+ return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`;
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
  // Serialised LLM batch concurrency: we want at most one
116
  // categorization sweep running at a time, regardless of how many
117
  // /api/js-apps requests come in. The flag also prevents the
 
157
  const author = spaceId.split('/')[0];
158
  const name = spaceId.split('/').pop();
159
 
160
+ // Server-resolved icon URL. Looks for `icon.svg` or `icon.png`
161
+ // at the repo root via the `siblings` list returned by
162
+ // `?full=true`. See `findIconUrl()` above for the rationale.
163
+ // `null` when the author hasn't shipped one; clients fall
164
+ // back to the front-matter emoji.
165
+ const iconUrl = findIconUrl(spaceId, space.siblings);
166
+
167
  return {
168
  // Core fields (used by both website and desktop)
169
  id: spaceId,
 
172
  url: `https://huggingface.co/spaces/${spaceId}`,
173
  source_kind: 'hf_space',
174
  isOfficial,
175
+ iconUrl,
176
+
177
  // Extra metadata (desktop-compatible structure)
178
  extra: {
179
  id: spaceId,