Spaces:
Running
feat(api): publish iconUrl on /api/js-apps from Space siblings
Browse filesDetects 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>
- docs/APP_ICON_CONVENTION.md +113 -0
- server/index.js +68 -1
|
@@ -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.
|
|
@@ -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,
|