Spaces:
Running
Running
Deploy Build Small Roulette — static space serendipity engine for the org
Browse files- README.md +20 -5
- index.html +48 -17
- js/api.js +69 -0
- js/app.js +172 -0
- js/bag.js +79 -0
- js/config.js +23 -0
- js/embed.js +105 -0
- js/oauth.js +65 -0
- js/store.js +63 -0
- js/tracks.js +13 -0
- js/ui.js +116 -0
- style.css +172 -18
README.md
CHANGED
|
@@ -1,10 +1,25 @@
|
|
| 1 |
---
|
| 2 |
-
title: Roulette
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
|
|
|
| 4 |
}
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|