ratandeep commited on
Commit
653b61d
·
1 Parent(s): b73da1b

Deploy Build Small Roulette — static space serendipity engine for the org

Browse files
Files changed (12) hide show
  1. README.md +20 -5
  2. index.html +48 -17
  3. js/api.js +69 -0
  4. js/app.js +172 -0
  5. js/bag.js +79 -0
  6. js/config.js +23 -0
  7. js/embed.js +105 -0
  8. js/oauth.js +65 -0
  9. js/store.js +63 -0
  10. js/tracks.js +13 -0
  11. js/ui.js +116 -0
  12. style.css +172 -18
README.md CHANGED
@@ -1,10 +1,25 @@
1
  ---
2
- title: Roulette
3
- emoji: 🦀
4
- colorFrom: pink
5
- colorTo: indigo
6
  sdk: static
 
7
  pinned: false
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Build Small Roulette
3
+ emoji: 🧭
4
+ colorFrom: yellow
5
+ colorTo: green
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ hf_oauth: true
10
+ hf_oauth_expiration_minutes: 480
11
+ license: mit
12
  ---
13
 
14
+ # Build Small Roulette 🧭
15
+
16
+ A serendipity engine for the **Build Small Hackathon**. Spins through every public Space in the
17
+ [`build-small-hackathon`](https://huggingface.co/build-small-hackathon) org one full-screen card at a
18
+ time — 👍 like, 💾 save, ⏭ shuffle. A shuffle bag guarantees you see every project before any repeats.
19
+
20
+ - **👍 Like** writes a real Hugging Face like when you're signed in (falls back to a local like otherwise).
21
+ - **💾 Save** bookmarks projects locally.
22
+ - **⏭ Next** / `Space` / `→` shuffles onward. `←` Prev, `L` like, `S` save, `O` open on HF, `?` shortcuts.
23
+
24
+ Static Space — no backend. The project list comes from the public Hub API; saves and local-likes live
25
+ in your browser. OAuth requests only `openid profile` (no repo access).
index.html CHANGED
@@ -1,19 +1,50 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Build Small Roulette</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="style.css" />
11
+ <script async src="https://unpkg.com/es-module-shims@1.7.0/dist/es-module-shims.js"></script>
12
+ <script type="importmap">
13
+ { "imports": { "@huggingface/hub": "https://cdn.jsdelivr.net/npm/@huggingface/hub@2.11.2/+esm" } }
14
+ </script>
15
+ </head>
16
+ <body>
17
+ <header id="topbar" class="topbar" hidden>
18
+ <div class="tb-left">
19
+ <span id="card-emoji" class="emoji"></span>
20
+ <span id="card-title" class="title"></span>
21
+ <span id="card-track" class="track-badge" hidden></span>
22
+ <a id="hf-likes" class="hf-likes" title="Likes on Hugging Face" target="_blank" rel="noopener"></a>
23
+ </div>
24
+ <div class="tb-right">
25
+ <button id="btn-like" class="btn btn-icon" title="Like (L)">👍</button>
26
+ <button id="btn-save" class="btn btn-icon" title="Save (S)">💾</button>
27
+ <button id="btn-next" class="btn btn-next" title="Next (Space/→)"><span class="compass">🧭</span> Next</button>
28
+ </div>
29
+ </header>
30
+
31
+ <main id="stage" class="stage">
32
+ <div id="loader" class="loader">Finding a trail…</div>
33
+ </main>
34
+
35
+ <footer id="bottombar" class="bottombar" hidden>
36
+ <button id="btn-prev" class="btn btn-ghost" title="Prev (←)">⏮ Prev</button>
37
+ <a id="btn-open" class="btn btn-ghost" target="_blank" rel="noopener" title="Open on HF (O)">↗ Open on HF</a>
38
+ <button id="btn-saved" class="btn btn-ghost" title="Saved">☰ Saved (0)</button>
39
+ <button id="btn-track" class="btn btn-ghost" title="Filter by track">⤮ All</button>
40
+ <span id="progress" class="progress"></span>
41
+ <span id="auth" class="auth"></span>
42
+ </footer>
43
+
44
+ <div id="drawer" class="drawer" hidden></div>
45
+ <div id="overlay" class="overlay" hidden></div>
46
+ <div id="toast" class="toast" hidden></div>
47
+
48
+ <script type="module" src="js/app.js"></script>
49
+ </body>
50
  </html>
js/api.js ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from "./config.js";
2
+ import { normalizeTrack } from "./tracks.js";
3
+
4
+ const DEFAULT_EMOJI = "🛖";
5
+
6
+ export function parseLinkHeader(header) {
7
+ if (!header) return null;
8
+ for (const part of header.split(",")) {
9
+ const m = part.match(/<([^>]+)>\s*;\s*rel="?next"?/);
10
+ if (m) return m[1];
11
+ }
12
+ return null;
13
+ }
14
+
15
+ export function filterEligible(spaces, denylist = CONFIG.denylist) {
16
+ const deny = new Set(denylist);
17
+ return spaces.filter((s) => !deny.has(s.id) && s.private !== true);
18
+ }
19
+
20
+ export function toViewModel(s) {
21
+ const card = s.cardData || {};
22
+ const name = s.id.includes("/") ? s.id.split("/").slice(1).join("/") : s.id;
23
+ const subdomain = s.subdomain || s.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
24
+ return {
25
+ id: s.id,
26
+ name,
27
+ title: card.title || name,
28
+ emoji: card.emoji || DEFAULT_EMOJI,
29
+ shortDescription: card.short_description || "",
30
+ likes: typeof s.likes === "number" ? s.likes : 0,
31
+ sdk: s.sdk || "",
32
+ track: normalizeTrack(card.tags),
33
+ colorFrom: card.colorFrom || "gray",
34
+ colorTo: card.colorTo || "gray",
35
+ embedUrl: `https://${subdomain}.hf.space`,
36
+ hfUrl: `${CONFIG.apiBase}/spaces/${s.id}`,
37
+ };
38
+ }
39
+
40
+ // ---- network (live-verified) ----
41
+
42
+ export async function fetchAllSpaces() {
43
+ let url = `${CONFIG.apiBase}/api/spaces?author=${CONFIG.org}&full=true&limit=${CONFIG.apiLimit}`;
44
+ const all = [];
45
+ for (let guard = 0; url && guard < 20; guard++) {
46
+ const res = await fetch(url);
47
+ if (!res.ok) throw new Error(`list fetch ${res.status}`);
48
+ const page = await res.json();
49
+ all.push(...page);
50
+ url = parseLinkHeader(res.headers.get("Link"));
51
+ }
52
+ return filterEligible(all).map(toViewModel);
53
+ }
54
+
55
+ export async function fetchAllSpacesWithRetry() {
56
+ try { return await fetchAllSpaces(); }
57
+ catch { return await fetchAllSpaces(); } // retry once; second failure throws to caller
58
+ }
59
+
60
+ export async function fetchDetail(id) {
61
+ const res = await fetch(`${CONFIG.apiBase}/api/spaces/${id}`);
62
+ if (!res.ok) throw new Error(`detail ${res.status}`);
63
+ const d = await res.json();
64
+ const stage = d?.runtime?.stage || null;
65
+ const domainsReady = Array.isArray(d?.runtime?.domains)
66
+ ? d.runtime.domains.some((x) => x.stage === "READY")
67
+ : false;
68
+ return { stage, domainsReady, gated: !!d.gated, disabled: !!d.disabled, likes: d.likes };
69
+ }
js/app.js ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from "./config.js";
2
+ import { Store } from "./store.js";
3
+ import { Bag } from "./bag.js";
4
+ import { TRACKS } from "./tracks.js";
5
+ import { fetchAllSpacesWithRetry } from "./api.js";
6
+ import { mountCard } from "./embed.js";
7
+ import * as ui from "./ui.js";
8
+ import { initOAuth, signIn, signOut, likeOnHf } from "./oauth.js";
9
+
10
+ const $ = (id) => document.getElementById(id);
11
+
12
+ const state = {
13
+ store: new Store({ key: CONFIG.storageKey }),
14
+ all: [], // all view models
15
+ byId: new Map(),
16
+ bag: null,
17
+ filterTrack: null, // null | "Backyard" | "Wood"
18
+ current: null, // current vm
19
+ controller: null, // mountCard controller
20
+ oauth: null,
21
+ };
22
+
23
+ function idsForTrack(track) {
24
+ const list = track ? state.all.filter((v) => v.track === track) : state.all;
25
+ return list.map((v) => v.id);
26
+ }
27
+ function eligibleIds() { return idsForTrack(state.filterTrack); }
28
+
29
+ function prefetchNext() {
30
+ if (!CONFIG.prefetchNext || !state.bag) return;
31
+ const peekId = state.bag.bag[state.bag.cursor];
32
+ const vm = peekId && state.byId.get(peekId);
33
+ if (!vm) return;
34
+ let link = document.getElementById("prefetch-link");
35
+ if (!link) {
36
+ link = document.createElement("link");
37
+ link.id = "prefetch-link";
38
+ link.rel = "preconnect";
39
+ document.head.append(link);
40
+ }
41
+ link.href = vm.embedUrl;
42
+ }
43
+
44
+ function show(vm) {
45
+ if (!vm) return;
46
+ state.controller?.destroy();
47
+ state.current = vm;
48
+ state.store.markSeen(vm.id);
49
+ ui.renderCard(vm, state.store);
50
+ ui.setProgress(state.bag.progress());
51
+ state.controller = mountCard($("stage"), vm, { onSkip: next });
52
+ if (CONFIG.resumeRound) state.store.saveRound({ ...state.bag.serialize(), track: state.filterTrack });
53
+ prefetchNext();
54
+ }
55
+
56
+ function next() { show(state.byId.get(state.bag.next())); }
57
+ function prev() { const id = state.bag.prev(); if (id) show(state.byId.get(id)); }
58
+
59
+ function cycleTrack() {
60
+ const order = [null, ...TRACKS];
61
+ const nextTrack = order[(order.indexOf(state.filterTrack) + 1) % order.length];
62
+ const ids = idsForTrack(nextTrack);
63
+ if (!ids.length) { ui.toast(`No spaces in ${nextTrack}`); return; }
64
+ state.filterTrack = nextTrack;
65
+ ui.setTrackFilterLabel(state.filterTrack);
66
+ state.bag.setIds(ids);
67
+ next();
68
+ }
69
+
70
+ function wireKeys() {
71
+ document.addEventListener("keydown", (e) => {
72
+ const tag = e.target.tagName;
73
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
74
+ switch (e.key) {
75
+ case " ": case "ArrowRight": e.preventDefault(); next(); break;
76
+ case "ArrowLeft": prev(); break;
77
+ case "l": case "L": $("btn-like").click(); break;
78
+ case "s": case "S": $("btn-save").click(); break;
79
+ case "o": case "O": if (state.current) window.open(state.current.hfUrl, "_blank"); break;
80
+ case "?": ui.showShortcuts(); break;
81
+ }
82
+ });
83
+ }
84
+
85
+ function wireButtons() {
86
+ $("btn-next").onclick = next;
87
+ $("btn-prev").onclick = prev;
88
+ $("btn-track").onclick = cycleTrack;
89
+ ui.setTrackFilterLabel(null);
90
+
91
+ $("btn-save").onclick = () => {
92
+ if (!state.current) return;
93
+ const on = state.store.toggleSaved(state.current.id);
94
+ ui.setSaveState(on);
95
+ ui.setSavedCount(state.store.savedIds().length);
96
+ ui.toast(on ? "Saved 💾" : "Removed from saved");
97
+ };
98
+
99
+ $("btn-saved").onclick = () => {
100
+ const vms = state.store.savedIds().map((id) => state.byId.get(id)).filter(Boolean);
101
+ ui.openDrawer(vms, {
102
+ onOpenHere: (id) => { $("drawer").hidden = true; show(state.byId.get(id)); },
103
+ onUnsave: (id) => { state.store.toggleSaved(id); ui.setSavedCount(state.store.savedIds().length); },
104
+ });
105
+ };
106
+ ui.setSavedCount(state.store.savedIds().length);
107
+
108
+ $("btn-like").onclick = async () => {
109
+ const vm = state.current;
110
+ if (!vm) return;
111
+ const turningOn = !state.store.isLiked(vm.id);
112
+ ui.setLikeState(turningOn); // optimistic
113
+ const where = await likeOnHf(vm.id, turningOn, state.oauth?.accessToken);
114
+ if (where === "hf") {
115
+ state.store.setHfLiked(vm.id, turningOn);
116
+ vm.likes = Math.max(0, (vm.likes || 0) + (turningOn ? 1 : -1));
117
+ ui.setHfLikes(vm.likes);
118
+ ui.toast(turningOn ? "Liked on Hugging Face 💚" : "Unliked");
119
+ } else {
120
+ state.store.setLocalLiked(vm.id, turningOn);
121
+ ui.toast(state.oauth
122
+ ? "Couldn't like on HF — saved locally. Open on HF to like officially ↗"
123
+ : "Saved your like locally — sign in to like on HF ↗");
124
+ }
125
+ };
126
+ }
127
+
128
+ function renderAuth() {
129
+ ui.renderAuth(state.oauth?.userInfo, {
130
+ onSignIn: signIn,
131
+ onSignOut: () => { signOut(); state.oauth = null; renderAuth(); },
132
+ });
133
+ }
134
+
135
+ function showError(msg) {
136
+ $("stage").innerHTML = `<div class="fallback"><strong>${msg}</strong>
137
+ <div class="fallback-actions">
138
+ <a class="btn" target="_blank" rel="noopener" href="${CONFIG.apiBase}/${CONFIG.org}">Visit the org ↗</a>
139
+ <button class="btn btn-ghost" onclick="location.reload()">Retry</button>
140
+ </div></div>`;
141
+ }
142
+
143
+ async function boot() {
144
+ wireKeys();
145
+ wireButtons();
146
+
147
+ try {
148
+ state.all = await fetchAllSpacesWithRetry();
149
+ } catch {
150
+ showError("Couldn't reach the Hugging Face API. Check your connection and retry.");
151
+ return;
152
+ }
153
+ if (!state.all.length) { showError("No projects found on the trail yet."); return; }
154
+ state.byId = new Map(state.all.map((v) => [v.id, v]));
155
+
156
+ state.oauth = await initOAuth();
157
+ renderAuth();
158
+
159
+ const saved = CONFIG.resumeRound ? state.store.loadRound() : null;
160
+ state.filterTrack = saved?.track ?? null;
161
+ ui.setTrackFilterLabel(state.filterTrack);
162
+ const canResume = saved && saved.track === state.filterTrack;
163
+ state.bag = new Bag(eligibleIds(), { state: canResume ? saved : null });
164
+ next();
165
+
166
+ if (!localStorage.getItem("bsr:hinted")) {
167
+ ui.toast("Spot a project · 👍 like · 💾 save · 🧭 Next (or hit Space). Press ? for keys.", 6000);
168
+ localStorage.setItem("bsr:hinted", "1");
169
+ }
170
+ }
171
+
172
+ boot();
js/bag.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function shuffle(arr, rng = Math.random) {
2
+ const a = [...arr];
3
+ for (let i = a.length - 1; i > 0; i--) {
4
+ const j = Math.floor(rng() * (i + 1));
5
+ [a[i], a[j]] = [a[j], a[i]];
6
+ }
7
+ return a;
8
+ }
9
+
10
+ export class Bag {
11
+ constructor(ids, { rng = Math.random, state = null } = {}) {
12
+ this.rng = rng;
13
+ if (state && Array.isArray(state.bag) && state.bag.length) {
14
+ this.ids = [...ids];
15
+ this.bag = state.bag;
16
+ this.cursor = state.cursor ?? 0;
17
+ this.round = state.round ?? 1;
18
+ this.history = [];
19
+ this.histPos = -1;
20
+ this.lastShown = null;
21
+ } else {
22
+ this.setIds(ids);
23
+ }
24
+ }
25
+
26
+ setIds(ids) {
27
+ this.ids = [...ids];
28
+ this.bag = shuffle(this.ids, this.rng);
29
+ this.cursor = 0;
30
+ this.round = 1;
31
+ this.history = [];
32
+ this.histPos = -1;
33
+ this.lastShown = null;
34
+ }
35
+
36
+ _pop() {
37
+ if (this.cursor >= this.bag.length) {
38
+ // new round: reshuffle, avoid immediate repeat of lastShown
39
+ this.round += 1;
40
+ this.cursor = 0;
41
+ if (this.ids.length > 1) {
42
+ do { this.bag = shuffle(this.ids, this.rng); }
43
+ while (this.bag[0] === this.lastShown);
44
+ } else {
45
+ this.bag = shuffle(this.ids, this.rng);
46
+ }
47
+ }
48
+ return this.bag[this.cursor++];
49
+ }
50
+
51
+ next() {
52
+ // if we stepped back, going next replays forward through history first
53
+ if (this.histPos < this.history.length - 1) {
54
+ this.histPos += 1;
55
+ this.lastShown = this.history[this.histPos];
56
+ return this.lastShown;
57
+ }
58
+ const id = this._pop();
59
+ this.history.push(id);
60
+ this.histPos = this.history.length - 1;
61
+ this.lastShown = id;
62
+ return id;
63
+ }
64
+
65
+ prev() {
66
+ if (this.histPos <= 0) return null;
67
+ this.histPos -= 1;
68
+ this.lastShown = this.history[this.histPos];
69
+ return this.lastShown;
70
+ }
71
+
72
+ progress() {
73
+ return { seen: Math.min(this.cursor, this.bag.length), total: this.bag.length, round: this.round };
74
+ }
75
+
76
+ serialize() {
77
+ return { bag: this.bag, cursor: this.cursor, round: this.round };
78
+ }
79
+ }
js/config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const CONFIG = {
2
+ org: "build-small-hackathon",
3
+ apiBase: "https://huggingface.co",
4
+ apiLimit: 1000,
5
+ denylist: ["build-small-hackathon/field-guide", "build-small-hackathon/HOME"],
6
+ skipUnhealthyStages: ["RUNTIME_ERROR", "BUILD_ERROR", "NO_APP_FILE", "PAUSED"],
7
+ loadTimeoutMs: 15000,
8
+ detailFetch: true,
9
+ autoAdvanceMs: 6000,
10
+ resumeRound: true,
11
+ prefetchNext: true,
12
+ hfHub: "https://cdn.jsdelivr.net/npm/@huggingface/hub@2.11.2/+esm",
13
+ oauthScopes: null,
14
+ storageKey: "bsr:v1",
15
+ oauthKey: "oauth",
16
+ trackMap: {
17
+ "track:backyard": "Backyard",
18
+ "track:backyard-ai": "Backyard",
19
+ "track:wood": "Wood",
20
+ "track:thousand-token-wood": "Wood",
21
+ "track:thousand_token_wood": "Wood",
22
+ },
23
+ };
js/embed.js ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from "./config.js";
2
+ import { fetchDetail } from "./api.js";
3
+
4
+ const ALLOW = "accelerometer; autoplay; camera; microphone; clipboard-read; clipboard-write; encrypted-media; geolocation; gyroscope; midi";
5
+ const SANDBOX = "allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals";
6
+
7
+ function el(tag, cls, html) {
8
+ const e = document.createElement(tag);
9
+ if (cls) e.className = cls;
10
+ if (html != null) e.innerHTML = html;
11
+ return e;
12
+ }
13
+
14
+ export function buildIframe(vm) {
15
+ const f = document.createElement("iframe");
16
+ f.src = vm.embedUrl;
17
+ f.title = vm.title;
18
+ f.loading = "eager";
19
+ f.setAttribute("allow", ALLOW);
20
+ f.setAttribute("sandbox", SANDBOX);
21
+ f.referrerPolicy = "no-referrer-when-downgrade";
22
+ return f;
23
+ }
24
+
25
+ function fallbackCard(vm, message, { onSkip, countdownMs }) {
26
+ const box = el("div", "fallback");
27
+ box.append(el("div", "fallback-emoji", vm.emoji));
28
+ box.append(el("strong", null, vm.title));
29
+ box.append(el("p", null, message));
30
+ const open = el("a", "btn");
31
+ open.textContent = "↗ Open on HF";
32
+ open.href = vm.hfUrl; open.target = "_blank"; open.rel = "noopener";
33
+ const skip = el("button", "btn btn-ghost");
34
+ skip.textContent = "Skip →";
35
+ skip.onclick = onSkip;
36
+ const actions = el("div", "fallback-actions");
37
+ actions.append(open, skip);
38
+ box.append(actions);
39
+ let timer = null;
40
+ if (countdownMs && onSkip) timer = setTimeout(onSkip, countdownMs);
41
+ box._cleanup = () => timer && clearTimeout(timer);
42
+ return box;
43
+ }
44
+
45
+ /**
46
+ * Mounts a card into `stage`. Returns a controller with destroy().
47
+ * onState(state) receives "loading" | "waking" | "ready" | "broken".
48
+ */
49
+ export function mountCard(stage, vm, { onState, onSkip } = {}) {
50
+ stage.innerHTML = "";
51
+ const loader = el("div", "loader", "Loading the demo…");
52
+ stage.append(loader);
53
+ onState?.("loading");
54
+
55
+ let destroyed = false;
56
+ let iframe = null;
57
+ let timeout = null;
58
+ let fb = null;
59
+
60
+ const showBroken = (msg) => {
61
+ if (destroyed) return;
62
+ iframe?.remove();
63
+ loader.remove();
64
+ fb?._cleanup?.();
65
+ fb = fallbackCard(vm, msg, { onSkip, countdownMs: CONFIG.autoAdvanceMs });
66
+ stage.append(fb);
67
+ onState?.("broken");
68
+ };
69
+
70
+ const embed = (waking) => {
71
+ if (destroyed) return;
72
+ if (waking) { loader.textContent = "Waking this Space up…"; onState?.("waking"); }
73
+ iframe = buildIframe(vm);
74
+ iframe.addEventListener("load", () => {
75
+ if (destroyed) return;
76
+ clearTimeout(timeout);
77
+ iframe.classList.add("ready"); // cross-fade in
78
+ loader.remove();
79
+ onState?.("ready");
80
+ });
81
+ stage.append(iframe);
82
+ timeout = setTimeout(
83
+ () => showBroken("This one didn't wake in time. Open it on Hugging Face, or skip ahead."),
84
+ CONFIG.loadTimeoutMs
85
+ );
86
+ };
87
+
88
+ if (CONFIG.detailFetch) {
89
+ fetchDetail(vm.id).then((d) => {
90
+ if (destroyed) return;
91
+ if (d.gated || d.disabled || CONFIG.skipUnhealthyStages.includes(d.stage)) {
92
+ showBroken("This one isn't running right now — catch it on Hugging Face.");
93
+ } else {
94
+ embed(d.stage === "SLEEPING");
95
+ }
96
+ }).catch(() => embed(false)); // detail failed — just try embedding
97
+ } else {
98
+ embed(false);
99
+ }
100
+
101
+ return {
102
+ el: () => iframe,
103
+ destroy() { destroyed = true; clearTimeout(timeout); fb?._cleanup?.(); iframe?.remove(); },
104
+ };
105
+ }
js/oauth.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from "./config.js";
2
+
3
+ // Lazily load @huggingface/hub (resolved via the importmap in index.html).
4
+ // Dynamic import means a CDN hiccup degrades liking only — never the whole app.
5
+ let _hub = null;
6
+ async function hub() {
7
+ if (!_hub) _hub = await import("@huggingface/hub");
8
+ return _hub;
9
+ }
10
+
11
+ function load(key) {
12
+ try { return JSON.parse(localStorage.getItem(key) || "null"); } catch { return null; }
13
+ }
14
+ function save(key, v) {
15
+ try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ }
16
+ }
17
+
18
+ /** Returns the active OAuthResult or null (handles the redirect-back leg on load). */
19
+ export async function initOAuth() {
20
+ let oauth = null;
21
+ try {
22
+ const { oauthHandleRedirectIfPresent } = await hub();
23
+ oauth = await oauthHandleRedirectIfPresent();
24
+ } catch {
25
+ oauth = null;
26
+ }
27
+ oauth = oauth || load(CONFIG.oauthKey);
28
+ if (oauth) {
29
+ const exp = new Date(oauth.accessTokenExpiresAt).getTime();
30
+ if (Number.isFinite(exp) && exp < Date.now()) oauth = null; // expired
31
+ }
32
+ if (oauth) save(CONFIG.oauthKey, oauth);
33
+ return oauth;
34
+ }
35
+
36
+ export async function signIn() {
37
+ try {
38
+ const { oauthLoginUrl } = await hub();
39
+ const opts = CONFIG.oauthScopes ? { scopes: CONFIG.oauthScopes } : {};
40
+ window.location.href = await oauthLoginUrl(opts);
41
+ } catch {
42
+ /* OAuth lib unavailable — stay signed out; likes go local */
43
+ }
44
+ }
45
+
46
+ export function signOut() {
47
+ try { localStorage.removeItem(CONFIG.oauthKey); } catch { /* ignore */ }
48
+ }
49
+
50
+ /**
51
+ * Attempts a real HF like/unlike. Returns "hf" on success, "local" on any failure/offline.
52
+ * Caller persists the local fallback + UI.
53
+ */
54
+ export async function likeOnHf(id, on, token) {
55
+ if (!token) return "local";
56
+ try {
57
+ const res = await fetch(`${CONFIG.apiBase}/api/spaces/${id}/like`, {
58
+ method: on ? "POST" : "DELETE",
59
+ headers: { Authorization: `Bearer ${token}` },
60
+ });
61
+ return res.ok ? "hf" : "local";
62
+ } catch {
63
+ return "local";
64
+ }
65
+ }
js/store.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DEFAULTS = () => ({ liked: {}, hfLiked: {}, saved: {}, seen: [], round: null });
2
+
3
+ export class Store {
4
+ constructor({ key = "bsr:v1", backend } = {}) {
5
+ this.key = key;
6
+ this.backend = backend || globalThis.localStorage;
7
+ this.data = this._read();
8
+ }
9
+
10
+ _read() {
11
+ try {
12
+ const raw = this.backend?.getItem(this.key);
13
+ if (!raw) return DEFAULTS();
14
+ return { ...DEFAULTS(), ...JSON.parse(raw) };
15
+ } catch {
16
+ return DEFAULTS();
17
+ }
18
+ }
19
+
20
+ _write() {
21
+ try {
22
+ this.backend?.setItem(this.key, JSON.stringify(this.data));
23
+ } catch {
24
+ /* storage full / blocked — ignore */
25
+ }
26
+ }
27
+
28
+ // saves
29
+ isSaved(id) { return !!this.data.saved[id]; }
30
+ toggleSaved(id) {
31
+ if (this.data.saved[id]) { delete this.data.saved[id]; this._write(); return false; }
32
+ this.data.saved[id] = Date.now(); this._write(); return true;
33
+ }
34
+ savedIds() {
35
+ return Object.keys(this.data.saved).sort((a, b) => this.data.saved[b] - this.data.saved[a]);
36
+ }
37
+
38
+ // local-like fallback
39
+ isLocalLiked(id) { return !!this.data.liked[id]; }
40
+ setLocalLiked(id, on) {
41
+ if (on) this.data.liked[id] = Date.now(); else delete this.data.liked[id];
42
+ this._write();
43
+ }
44
+
45
+ // confirmed real HF like (this session/device)
46
+ isHfLiked(id) { return !!this.data.hfLiked[id]; }
47
+ setHfLiked(id, on) {
48
+ if (on) this.data.hfLiked[id] = true; else delete this.data.hfLiked[id];
49
+ this._write();
50
+ }
51
+
52
+ // any like (real or local) — for button fill state
53
+ isLiked(id) { return this.isHfLiked(id) || this.isLocalLiked(id); }
54
+
55
+ // seen / progress
56
+ markSeen(id) {
57
+ if (!this.data.seen.includes(id)) { this.data.seen.push(id); this._write(); }
58
+ }
59
+
60
+ // resume round
61
+ saveRound(round) { this.data.round = round; this._write(); }
62
+ loadRound() { return this.data.round; }
63
+ }
js/tracks.js ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from "./config.js";
2
+
3
+ export const TRACKS = ["Backyard", "Wood"];
4
+
5
+ export function normalizeTrack(tags) {
6
+ if (!Array.isArray(tags)) return null;
7
+ for (const t of tags) {
8
+ if (typeof t !== "string") continue;
9
+ const hit = CONFIG.trackMap[t.toLowerCase()];
10
+ if (hit) return hit;
11
+ }
12
+ return null;
13
+ }
js/ui.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const $ = (id) => document.getElementById(id);
2
+
3
+ export function renderCard(vm, store) {
4
+ $("topbar").hidden = false;
5
+ $("bottombar").hidden = false;
6
+ $("card-emoji").textContent = vm.emoji;
7
+ $("card-title").textContent = vm.title;
8
+ const track = $("card-track");
9
+ track.hidden = !vm.track;
10
+ track.textContent = vm.track || "";
11
+ const likes = $("hf-likes");
12
+ likes.textContent = `♥ ${vm.likes}`;
13
+ likes.href = vm.hfUrl;
14
+ $("btn-open").href = vm.hfUrl;
15
+ setLikeState(store.isLiked(vm.id));
16
+ setSaveState(store.isSaved(vm.id));
17
+ }
18
+
19
+ export function setLikeState(on) { $("btn-like").classList.toggle("on", !!on); }
20
+ export function setSaveState(on) { $("btn-save").classList.toggle("on", !!on); }
21
+
22
+ export function setHfLikes(n) { $("hf-likes").textContent = `♥ ${n}`; }
23
+
24
+ export function setProgress(p) {
25
+ $("progress").textContent = `↳ ${p.seen} / ${p.total} seen · lap ${p.round}`;
26
+ }
27
+
28
+ export function setSavedCount(n) { $("btn-saved").textContent = `☰ Saved (${n})`; }
29
+
30
+ export function setTrackFilterLabel(track) {
31
+ $("btn-track").textContent = `⤮ ${track || "All"}`;
32
+ }
33
+
34
+ let toastTimer = null;
35
+ export function toast(msg, ms = 2600) {
36
+ const t = $("toast");
37
+ t.textContent = msg;
38
+ t.hidden = false;
39
+ clearTimeout(toastTimer);
40
+ toastTimer = setTimeout(() => (t.hidden = true), ms);
41
+ }
42
+
43
+ export function renderAuth(user, { onSignIn, onSignOut }) {
44
+ const a = $("auth");
45
+ a.innerHTML = "";
46
+ if (user) {
47
+ const img = document.createElement("img");
48
+ img.src = user.picture; img.width = 22; img.height = 22;
49
+ img.className = "avatar";
50
+ const out = document.createElement("button");
51
+ out.className = "btn btn-ghost";
52
+ out.textContent = `@${user.preferred_username} ⏏`;
53
+ out.onclick = onSignOut;
54
+ a.append(img, out);
55
+ } else {
56
+ const inBtn = document.createElement("button");
57
+ inBtn.className = "btn btn-ghost";
58
+ inBtn.textContent = "Sign in to like on HF";
59
+ inBtn.onclick = onSignIn;
60
+ a.append(inBtn);
61
+ }
62
+ }
63
+
64
+ const NAMED = {
65
+ indigo: "#4f46e5", green: "#16a34a", blue: "#2563eb", red: "#dc2626", yellow: "#eab308",
66
+ purple: "#9333ea", pink: "#db2777", orange: "#ea580c", gray: "#6b7280", teal: "#0d9488",
67
+ };
68
+ function cssColor(name) { return NAMED[name] || "#6b7280"; }
69
+
70
+ export function openDrawer(savedVms, { onOpenHere, onUnsave }) {
71
+ const d = $("drawer");
72
+ d.hidden = false;
73
+ d.innerHTML = `<div class="drawer-head">
74
+ <strong>Saved (${savedVms.length})</strong>
75
+ <button class="btn btn-ghost" id="drawer-close">✕</button></div>`;
76
+ $("drawer-close").onclick = () => (d.hidden = true);
77
+ if (!savedVms.length) {
78
+ d.insertAdjacentHTML("beforeend", `<p class="drawer-empty">Nothing saved yet. Tap 💾 on a project you like.</p>`);
79
+ return;
80
+ }
81
+ for (const vm of savedVms) {
82
+ const row = document.createElement("div");
83
+ row.className = "saved-row";
84
+ row.innerHTML = `
85
+ <span class="saved-thumb" style="background:linear-gradient(135deg,${cssColor(vm.colorFrom)},${cssColor(vm.colorTo)})">${vm.emoji}</span>
86
+ <span class="saved-meta">
87
+ <span class="saved-title">${vm.title}</span>
88
+ <span class="saved-sub">${vm.track || vm.sdk}</span>
89
+ </span>`;
90
+ row.onclick = () => onOpenHere(vm.id);
91
+ const un = document.createElement("button");
92
+ un.className = "btn btn-ghost";
93
+ un.textContent = "🗑";
94
+ un.onclick = (e) => { e.stopPropagation(); onUnsave(vm.id); row.remove(); };
95
+ row.append(un);
96
+ d.append(row);
97
+ }
98
+ }
99
+
100
+ export function showShortcuts() {
101
+ const o = $("overlay");
102
+ o.hidden = false;
103
+ o.innerHTML = `<div class="overlay-card">
104
+ <h3>Shortcuts</h3>
105
+ <ul class="keys">
106
+ <li><kbd>Space</kbd> / <kbd>→</kbd> Next</li>
107
+ <li><kbd>←</kbd> Prev</li>
108
+ <li><kbd>L</kbd> Like</li>
109
+ <li><kbd>S</kbd> Save</li>
110
+ <li><kbd>O</kbd> Open on HF</li>
111
+ <li><kbd>?</kbd> This help</li>
112
+ </ul>
113
+ <button class="btn btn-next" id="ov-close">Got it</button></div>`;
114
+ $("ov-close").onclick = () => (o.hidden = true);
115
+ o.onclick = (e) => { if (e.target === o) o.hidden = true; };
116
+ }
style.css CHANGED
@@ -1,28 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
 
 
 
 
 
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
16
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
28
  }
 
1
+ /* Build Small Roulette — "dusk on the trail" field-guide theme.
2
+ The embedded Space is the star: chrome stays dark, thin, and disciplined.
3
+ Boldness is spent in one place — the specimen field-tag + dotted trail + compass. */
4
+ :root {
5
+ --bark: #20281f; /* deep forest, near-black — persistent bars */
6
+ --bark-2: #161a14; /* stage backdrop */
7
+ --parchment: #f3ead6; /* warm paper — journal panels + text on bark */
8
+ --blaze: #e2602a; /* trail-blaze orange — Next + active, used sparingly */
9
+ --lichen: #b8c4a9; /* muted sage — secondary/data text */
10
+ --moss: #3a5a40; /* forest — track badge */
11
+ --ink: #2a2620; /* text on parchment */
12
+ --line: rgba(184,196,169,.20);
13
+
14
+ --font-display: "Fraunces", Georgia, "Times New Roman", serif;
15
+ --font-mono: "Space Mono", ui-monospace, "SFMono-Regular", monospace;
16
+ --font-body: system-ui, "Segoe UI", sans-serif;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ html, body { height: 100%; margin: 0; }
20
  body {
21
+ font: 15px/1.45 var(--font-body);
22
+ color: var(--ink); background: var(--bark-2);
23
+ display: grid; grid-template-rows: auto 1fr auto; height: 100dvh; overflow: hidden;
24
  }
25
 
26
+ /* ---- persistent bars ---- */
27
+ .topbar, .bottombar {
28
+ display: flex; align-items: center; gap: 10px;
29
+ background: var(--bark); color: var(--parchment); padding: 9px 14px; z-index: 5;
30
+ }
31
+ .topbar { border-bottom: 2px solid var(--blaze); } /* the trail blaze */
32
+ .bottombar {
33
+ font: 12px/1 var(--font-mono); letter-spacing: .02em;
34
+ border-top: 1.5px dotted rgba(184,196,169,.4); /* the trail line */
35
+ }
36
+ .tb-left { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
37
+ .tb-right { display: flex; align-items: center; gap: 8px; }
38
+
39
+ .emoji {
40
+ font-size: 18px; line-height: 1; flex: 0 0 auto;
41
+ width: 30px; height: 30px; display: grid; place-items: center;
42
+ background: rgba(243,234,214,.08); border: 1px solid var(--line); border-radius: 9px;
43
+ }
44
+ .title {
45
+ font: 600 19px/1.1 var(--font-display); letter-spacing: -.01em;
46
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
47
+ }
48
+ .track-badge {
49
+ font: 700 10px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
50
+ padding: 4px 9px; border-radius: 4px; flex: 0 0 auto;
51
+ background: var(--moss); color: #eafdf0; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
52
  }
53
+ .hf-likes {
54
+ font: 400 12px/1 var(--font-mono); color: var(--lichen);
55
+ text-decoration: none; white-space: nowrap; flex: 0 0 auto;
56
+ }
57
+ .hf-likes:hover { color: var(--parchment); }
58
 
59
+ /* ---- buttons ---- */
60
+ .btn {
61
+ font: inherit; cursor: pointer; border: 0; border-radius: 9px;
62
+ background: transparent; color: inherit; padding: 8px 10px;
63
+ transition: transform .12s ease, background .15s ease, opacity .15s ease;
64
+ }
65
+ .btn-icon {
66
+ font-size: 17px; line-height: 1; width: 38px; height: 36px;
67
+ background: rgba(243,234,214,.08); border: 1px solid var(--line);
68
  }
69
+ .btn-icon:hover { background: rgba(243,234,214,.16); }
70
+ .btn-icon.on { background: var(--blaze); border-color: var(--blaze); animation: pop .32s ease; }
71
+ .btn-icon:active { transform: scale(.9); }
72
+ .btn-next {
73
+ font: 700 14px/1 var(--font-body); letter-spacing: .01em;
74
+ background: var(--blaze); color: #fff; padding: 11px 18px; display: inline-flex; gap: 7px; align-items: center;
75
+ box-shadow: 0 1px 0 rgba(0,0,0,.25);
76
+ }
77
+ .btn-next:hover { filter: brightness(1.06); }
78
+ .btn-next:active { transform: translateY(1px); }
79
+ .btn-next .compass { display: inline-block; font-size: 15px; transition: transform .45s cubic-bezier(.34,1.56,.64,1); }
80
+ .btn-next:hover .compass { transform: rotate(72deg); }
81
+ .btn-ghost { color: var(--parchment); opacity: .8; }
82
+ .btn-ghost:hover { opacity: 1; background: rgba(243,234,214,.08); }
83
+
84
+ .progress { margin-left: auto; color: var(--lichen); white-space: nowrap; }
85
+ .auth { display: flex; align-items: center; gap: 7px; }
86
+ .avatar { border-radius: 50%; border: 1px solid var(--line); }
87
 
88
+ @keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.22); } 100% { transform: scale(1); } }
89
+
90
+ /* ---- stage + iframe ---- */
91
+ .stage {
92
+ position: relative; background-color: var(--bark-2); overflow: hidden;
93
+ background-image:
94
+ linear-gradient(rgba(184,196,169,.045) 1px, transparent 1px),
95
+ linear-gradient(90deg, rgba(184,196,169,.045) 1px, transparent 1px); /* field-notebook grid */
96
+ background-size: 28px 28px;
97
+ }
98
+ .stage iframe {
99
+ position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
100
+ opacity: 0; transition: opacity .4s ease;
101
+ }
102
+ .stage iframe.ready { opacity: 1; }
103
+
104
+ .loader, .fallback {
105
+ position: absolute; inset: 0; display: flex; flex-direction: column;
106
+ align-items: center; justify-content: center; gap: 14px; color: var(--parchment);
107
+ text-align: center; padding: 24px;
108
+ }
109
+ .loader { font: 400 14px/1.4 var(--font-mono); color: var(--lichen); letter-spacing: .04em; }
110
+ .loader::after { content: " ·"; animation: blink 1.1s steps(3, end) infinite; }
111
+ @keyframes blink { 0% { opacity: .2; } 50% { opacity: 1; } 100% { opacity: .2; } }
112
+ .fallback strong { font: 600 22px/1.2 var(--font-display); }
113
+ .fallback p { max-width: 32ch; margin: 0; color: var(--lichen); }
114
+ .fallback-emoji { font-size: 44px; }
115
+ .fallback-actions { display: flex; gap: 10px; }
116
+ .fallback .btn { background: var(--blaze); color: #fff; }
117
+ .fallback .btn-ghost { background: rgba(243,234,214,.12); color: var(--parchment); }
118
+
119
+ /* ---- drawer (the field journal) ---- */
120
+ .drawer {
121
+ position: fixed; top: 0; right: 0; width: min(380px, 92vw); height: 100%;
122
+ background: var(--parchment); color: var(--ink); z-index: 20; padding: 18px; overflow: auto;
123
+ box-shadow: -10px 0 30px rgba(0,0,0,.35); border-left: 3px solid var(--blaze);
124
+ }
125
+ .drawer-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
126
+ .drawer-head strong { font: 600 19px/1 var(--font-display); }
127
+ .drawer-head .btn-ghost { color: var(--ink); }
128
+ .drawer-empty { color: rgba(42,38,32,.7); }
129
+ .saved-row { display: flex; gap: 11px; align-items: center; padding: 9px; border-radius: 10px; cursor: pointer; }
130
+ .saved-row:hover { background: rgba(42,38,32,.07); }
131
+ .saved-thumb {
132
+ width: 36px; height: 36px; border-radius: 9px; display: grid; place-items: center;
133
+ font-size: 18px; flex: 0 0 auto; box-shadow: inset 0 0 0 1px rgba(0,0,0,.15);
134
+ }
135
+ .saved-meta { flex: 1; min-width: 0; }
136
+ .saved-title { display: block; font: 600 15px/1.2 var(--font-display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
137
+ .saved-sub { font: 400 11px/1 var(--font-mono); color: rgba(42,38,32,.6); text-transform: uppercase; letter-spacing: .08em; }
138
+ .saved-row .btn-ghost { color: var(--ink); opacity: .55; }
139
+ .saved-row .btn-ghost:hover { opacity: 1; background: rgba(42,38,32,.08); }
140
+
141
+ /* ---- overlay (shortcuts) ---- */
142
+ .overlay {
143
+ position: fixed; inset: 0; background: rgba(22,26,20,.9); color: var(--parchment);
144
+ z-index: 30; display: flex; align-items: center; justify-content: center; padding: 24px;
145
+ }
146
+ .overlay-card {
147
+ max-width: 320px; background: var(--bark); border: 1px solid var(--line);
148
+ border-radius: 14px; padding: 22px 24px; box-shadow: 0 20px 60px rgba(0,0,0,.5);
149
+ }
150
+ .overlay-card h3 { font: 600 20px/1 var(--font-display); margin: 0 0 14px; }
151
+ .keys { list-style: none; padding: 0; margin: 0 0 18px; font: 13px/1 var(--font-mono); }
152
+ .keys li { display: flex; gap: 8px; align-items: center; padding: 6px 0; color: var(--lichen); }
153
+ kbd {
154
+ background: rgba(243,234,214,.1); border: 1px solid var(--line); border-radius: 5px;
155
+ padding: 2px 7px; font: 12px/1 var(--font-mono); color: var(--parchment); min-width: 18px; text-align: center;
156
+ }
157
+
158
+ /* ---- toast ---- */
159
+ .toast {
160
+ position: fixed; bottom: 64px; left: 50%; transform: translateX(-50%);
161
+ background: var(--bark); color: var(--parchment); border: 1px solid var(--line);
162
+ padding: 11px 16px; border-radius: 10px; z-index: 40; max-width: 90vw; text-align: center;
163
+ font: 13px/1.3 var(--font-mono); box-shadow: 0 8px 24px rgba(0,0,0,.4);
164
+ }
165
+
166
+ /* ---- accessibility floor ---- */
167
+ .btn:focus-visible, .saved-row:focus-visible, a:focus-visible {
168
+ outline: 2px solid var(--blaze); outline-offset: 2px;
169
+ }
170
+ @media (prefers-reduced-motion: reduce) {
171
+ *, *::after { animation: none !important; transition: none !important; }
172
+ .stage iframe { opacity: 1; }
173
  }
174
 
175
+ /* ---- mobile ---- */
176
+ @media (max-width: 620px) {
177
+ .title { font-size: 17px; }
178
+ .btn-next { padding: 11px 14px; }
179
+ .bottombar { gap: 8px; flex-wrap: wrap; }
180
+ .hf-likes { display: none; }
181
+ .progress { margin-left: 0; order: 9; flex-basis: 100%; }
182
  }