Status pill + git-SHA asset versioning

- _origin_probe(): 3-min check of anidb.app pages/API; OK/DOWN + reason
  in GET /api/cdn and server.log ([probe] ORIGIN ...)
- header pill: upstream-site dot (red = down) + CDN dot + label;
  per-dot hover tooltips, tap -> toast legend (mobile has no hover)
- ?v={{VERSION}} injected from 'git describe --always --dirty' at
  startup; JSV reads it from its own script URL - replaces hand-bumped
  numbers (JSV/?v drift) and gives play-probes the running commit
This commit is contained in:
jozamudi committed 2026-09-03 16:33:51 +00:00
1 parent 3f715434f0
commit ff8311c1a8
4 files changed
+178 -59

No files matched your search

+53 -3
View File
@@ -7,6 +7,7 @@ Cloudflare workaround) and rewrites playlist URIs to go through itself.
import base64 import base64
import json import json
import os import os
import subprocess
import threading import threading
import time import time
from collections import Counter, OrderedDict from collections import Counter, OrderedDict
@@ -16,7 +17,7 @@ from base64 import urlsafe_b64encode, urlsafe_b64decode
from urllib.parse import quote, urljoin, urlparse from urllib.parse import quote, urljoin, urlparse
from fastapi import Body, FastAPI, HTTPException, Query, Request from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner from itsdangerous import BadSignature, TimestampSigner
@@ -25,8 +26,28 @@ from . import db, upstream, users
ROOT = Path(__file__).parent.parent ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web") app = FastAPI(title="ani-web")
def _version():
"""Cache-bust token: git short SHA (+ -dirty). The content of a commit
never changes, so ?v=<sha> is a correct cache key — this replaces the
hand-bumped ?v= number (forgetting a bump = users stuck on stale JS).
Every deploy restarts the process (run.sh), so the read is fresh."""
try:
v = subprocess.run(["git", "describe", "--always", "--dirty"], cwd=ROOT,
capture_output=True, text=True, timeout=5).stdout.strip()
if v:
return v
except Exception:
pass
return "dev"
VERSION = _version()
# latest probe result + last 20 samples (~1h at 3-min spacing), for GET /api/cdn # latest probe result + last 20 samples (~1h at 3-min spacing), for GET /api/cdn
_CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []} _CDN = {"state": "unknown", "kbps": 0.0, "ts": 0.0, "hist": []}
# origin (anidb.app pages/API) health — separate host, separate failure mode
# (observed 2026-09-03: CDN fine while origin 503'd "Under Maintenance")
_ORIGIN = {"state": "unknown", "msg": "", "ts": 0.0}
def _cdn_probe(): def _cdn_probe():
@@ -62,16 +83,34 @@ def _cdn_probe():
_CDN.update(state=now, kbps=round(kbps), ts=time.time()) _CDN.update(state=now, kbps=round(kbps), ts=time.time())
_CDN["hist"].append({"t": time.time(), "kbps": round(kbps)}) _CDN["hist"].append({"t": time.time(), "kbps": round(kbps)})
del _CDN["hist"][:-20] del _CDN["hist"][:-20]
_origin_probe()
time.sleep(180) time.sleep(180)
def _origin_probe():
"""Hit the same kind of page users load (details page) every 3 min and
track OK/DOWN — this is the host that 503s 'Under Maintenance'.
ponytail: one URL, no history; the log line + ts tell the when."""
try:
r = upstream._get("https://anidb.app/anime/jujutsu-kaisen-season-2-2554",
timeout=10)
state, msg = "OK", f"http {r.status_code}"
except Exception as e:
state, msg = "DOWN", str(e)
prev = _ORIGIN["state"]
_ORIGIN.update(state=state, msg=msg, ts=time.time())
if state != prev:
print(f"[probe] ORIGIN {state} ({msg})", flush=True)
threading.Thread(target=_cdn_probe, daemon=True).start() threading.Thread(target=_cdn_probe, daemon=True).start()
@app.get("/api/cdn") @app.get("/api/cdn")
async def cdn_status(): async def cdn_status():
"""CDN health (read-only, public): latest probe + ~1h of history.""" """CDN + origin health (read-only, public). Top-level fields are the CDN
return _CDN probe (existing shape); .origin is the anidb.app pages/API probe."""
return {**_CDN, "origin": _ORIGIN}
# ---------- auth: multi-user, signed cookies ---------- # ---------- auth: multi-user, signed cookies ----------
users.migrate_legacy() # file-based legacy import; db.init() absorbs its output users.migrate_legacy() # file-based legacy import; db.init() absorbs its output
@@ -621,4 +660,15 @@ def stream(token: str):
return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS) return Response(body, media_type=ct, headers=_STREAM_CACHE_HDRS)
@app.get("/", include_in_schema=False)
@app.get("/index.html", include_in_schema=False)
def index():
# index.html is served (not mounted) so ?v={{VERSION}} becomes the git
# SHA at startup; other statics go through the mount below
return HTMLResponse(_INDEX_HTML.replace("{{VERSION}}", VERSION))
_INDEX_HTML = (ROOT / "static" / "index.html").read_text()
app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static") app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")
+99 -43
View File
@@ -1,6 +1,8 @@
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js // app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
const JSV = 14; // bump with every app.js change; play-probe reports it so a // JSV = this file's cache-bust version (the server injects the git SHA into
// stale-cache phone is detectable server-side // its ?v= URL), reported by play-probes so a stale-cache phone is
// detectable server-side. No hand-bumping.
const JSV = ((document.currentScript && document.currentScript.src).match(/[?&]v=([^&]+)/) || [])[1] || "unknown";
// BASE = this page's directory, so the app works at / or behind a // BASE = this page's directory, so the app works at / or behind a
// prefixing reverse proxy (e.g. https://host/ani/) // prefixing reverse proxy (e.g. https://host/ani/)
const BASE = (() => { const BASE = (() => {
@@ -31,6 +33,16 @@ function el(tag, cls, text) {
return e; return e;
} }
// lazy poster img with a broken-image fallback (a dead upstream poster URL
// must not leave a broken-image icon in an otherwise fine card)
function poster(src, alt) {
const i = document.createElement("img");
i.alt = alt || "";
if (src) { i.src = src; i.loading = "lazy"; i.decoding = "async"; }
i.onerror = () => i.replaceWith(el("div", "ph-img"));
return i;
}
function fail(err) { function fail(err) {
main.innerHTML = ""; main.innerHTML = "";
main.append(el("p", "error", "Error: " + err.message)); main.append(el("p", "error", "Error: " + err.message));
@@ -129,10 +141,7 @@ const homeCard = x => {
const done = histClass(x) === "done", hold = histClass(x) === "hold"; const done = histClass(x) === "done", hold = histClass(x) === "hold";
const c = el("a", "card" + (hold ? " hold" : "")); const c = el("a", "card" + (hold ? " hold" : ""));
c.href = histHref(x); c.href = histHref(x);
if (x.poster) { c.append(poster(x.poster, x.title));
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`; let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
if (done) meta += " \u00b7 \u2713"; if (done) meta += " \u00b7 \u2713";
else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`; else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
@@ -233,31 +242,53 @@ function saveSearch(q) {
body: JSON.stringify({q})}).catch(() => {}); body: JSON.stringify({q})}).catch(() => {});
} }
async function showSearch(q) { function renderSearchResults(q, results, announce = true) {
saveSearch(q);
document.title = q + " \u2014 ani-web"; document.title = q + " \u2014 ani-web";
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = ""; main.innerHTML = "";
if (!results.length) { if (!results.length) {
const p = el("p", "muted", "No results."); const p = el("p", "muted", "No results.");
main.append(p); main.append(p);
recentChips(p); recentChips(p);
focusMain(); } else {
return; main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
const grid = el("div", "grid");
for (const r of results) {
const card = el("a", "card");
card.href = `?anime=${r.slug_id}`;
card.append(poster(r.poster, r.name), el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
} }
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`)); if (announce) focusMain(); // focus-steal breaks Enter-to-submit mid-keystroke
const grid = el("div", "grid"); }
for (const r of results) {
const card = el("a", "card"); async function showSearch(q) {
card.href = `?anime=${r.slug_id}`; saveSearch(q);
const img = el("img", null, r.name); skel(8);
img.src = r.poster || ""; renderSearchResults(q, await api(`/api/search?q=${encodeURIComponent(q)}`));
card.append(img, el("span", "card-name", r.name)); }
grid.append(card);
} // search-as-you-type: debounced /api/search rendered in place (Enter still
main.append(grid); // commits to ?q= so results are shareable/reloadable)
focusMain(); let liveTok = 0;
function initLive() {
let t = null;
const q = document.getElementById("q");
q.addEventListener("input", () => {
const v = q.value.trim();
clearTimeout(t);
if (v.length < 2) return;
t = setTimeout(async () => {
const tok = ++liveTok;
try {
const results = await api(`/api/search?q=${encodeURIComponent(v)}`);
if (tok !== liveTok) return; // superseded by a newer keystroke
saveSearch(v);
renderSearchResults(v, results, false); // keep focus in #q so Enter still submits
} catch { /* failed or stale — Enter still works */ }
}, 300);
});
} }
// ---------- recent searches dropdown ---------- // ---------- recent searches dropdown ----------
@@ -349,6 +380,7 @@ async function showAnime(slug_id, opts = {}) {
const jump = el("input", "spd"); const jump = el("input", "spd");
jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric"; jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric";
jump.placeholder = "E#"; jump.title = "jump to episode"; jump.placeholder = "E#"; jump.title = "jump to episode";
jump.ariaLabel = "jump to episode";
jump.style.width = "4.5rem"; jump.style.width = "4.5rem";
jump.onchange = () => { jump.onchange = () => {
const t = epsList.find(e => e.number == +jump.value); const t = epsList.find(e => e.number == +jump.value);
@@ -450,10 +482,7 @@ function histCard(x) {
const hold = histClass(x) === "hold"; const hold = histClass(x) === "hold";
const c = el("div", "card hist" + (hold ? " hold" : "")); const c = el("div", "card hist" + (hold ? " hold" : ""));
c.onclick = () => location.href = histHref(x); c.onclick = () => location.href = histHref(x);
if (x.poster) { c.append(poster(x.poster, x.title));
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
const nm = el("a", "card-name", x.title); const nm = el("a", "card-name", x.title);
nm.href = histHref(x); nm.href = histHref(x);
const info = el("div", "mini-info"); const info = el("div", "mini-info");
@@ -529,10 +558,7 @@ function topAnime(d, n = 0) {
ta.append(el("h2", null, "Most watched")); ta.append(el("h2", null, "Most watched"));
for (const a of list) { for (const a of list) {
const row = el("div", "stat-anime"); const row = el("div", "stat-anime");
if (a.poster) { row.append(poster(a.poster, a.title));
const i = el("img"); i.src = a.poster; i.alt = "";
row.append(i);
} else row.append(el("div", "sa-ph"));
const t1 = el("div", "sa-top"); const t1 = el("div", "sa-top");
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a))); t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
row.append(t1, row.append(t1,
@@ -792,16 +818,44 @@ function bufferGauge() {
bufTimer = setInterval(tick, 250); bufTimer = setInterval(tick, 250);
} }
function cdnChip() { function statusPill() {
// corner badge: live CDN throughput from /api/cdn (dot: green=OK, amber=degraded) // header badge: upstream site + CDN status from /api/cdn. Lives in the
const p = document.getElementById("player"); // (sticky) header so it's visible on the home screen — if the upstream is
let c = p.querySelector(".pl-cdn"); // down you never reach the player, so that's where the status must live.
if (!c) { c = el("div", "pl-cdn"); c.append(el("i")); p.append(c); } const p = document.getElementById("pill");
if (!p) return;
api("/api/cdn").then(d => { api("/api/cdn").then(d => {
c.className = "pl-cdn " + (d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : ""); const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : "";
c.lastChild.textContent = d.kbps + " KB/s"; const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "";
c.title = "CDN " + d.state + " — " + d.kbps + " KB/s"; p.className = "pill" + (o === "down" ? " odown" : "");
}).catch(() => { c.className = "pl-cdn"; c.lastChild.textContent = "CDN ?"; }); const od = p.querySelector(".o"), cd = p.querySelector(".c");
od.className = "o " + o;
cd.className = "c " + c;
p.querySelector("span").textContent =
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up";
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers)
od.title = "upstream site (anidb.app pages + API): " + d.origin.state
+ (d.origin.msg ? " — " + d.origin.msg : "");
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state
+ " — " + d.kbps + " KB/s";
p.title = ""; // dot-level titles carry the detail; pill-level would shadow them
p.setAttribute("aria-label", "upstream site " + d.origin.state
+ (d.origin.msg ? ": " + d.origin.msg : "")
+ "; CDN " + d.state + ", " + d.kbps + " KB/s");
// tap/click = tooltip for touch screens (mobile has no hover);
// read p._data at click time so the toast reflects the LATEST status,
// not the one captured when the handler was first bound
if (!p._bound) {
p._bound = 1;
p.onclick = e => {
e.stopPropagation();
const d = p._data;
toast("upstream site: " + d.origin.state + (d.origin.msg ? " (" + d.origin.msg + ")" : "")
+ " · CDN: " + d.state + " (" + d.kbps + " KB/s)");
};
}
p._data = d;
}).catch(() => { p.querySelector("span").textContent = "?"; });
} }
// ---------- auto-next on episode end (5s countdown) ---------- // ---------- auto-next on episode end (5s countdown) ----------
@@ -876,7 +930,7 @@ async function play(ep_id, ep_no) {
document.getElementById("player").replaceChildren(v); document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026", null, null, true); playerOverlay("Loading\u2026", null, null, true);
bufferGauge(); bufferGauge();
cdnChip(); statusPill();
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're // DECISIVE PROBE (iPhone debugging): report exactly which branch we're
// about to take + the browser's real capabilities. // about to take + the browser's real capabilities.
try { try {
@@ -966,7 +1020,7 @@ async function play(ep_id, ep_no) {
v.addEventListener("waiting", () => { v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) { if (currentEp && !playerBroken && !v.ended) {
playerOverlay("Buffering\u2026", null, null, true); playerOverlay("Buffering\u2026", null, null, true);
cdnChip(); // refresh CDN readout exactly when it matters statusPill(); // refresh status readout exactly when it matters
} }
}); });
v.addEventListener("ended", onEnded); v.addEventListener("ended", onEnded);
@@ -1061,6 +1115,7 @@ window.addEventListener("pagehide", savePos);
const me = await api("/api/me"); const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user; document.getElementById("me").textContent = "hi, " + me.user;
initRecent(); initRecent();
initLive();
const lo = document.getElementById("logout"); const lo = document.getElementById("logout");
lo.onclick = async () => { lo.onclick = async () => {
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1"; await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
@@ -1082,6 +1137,7 @@ window.addEventListener("pagehide", savePos);
await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1"; await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1";
}; };
} catch { /* not logged in: api() already redirected to /login */ } } catch { /* not logged in: api() already redirected to /login */ }
statusPill();
await refreshHistory(); await refreshHistory();
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
try { try {
+12 -7
View File
@@ -3,17 +3,22 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0e0f13">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>ani-web</title> <title>ani-web</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23f97316'/%3E%3Ctext x='32' y='45' font-family='sans-serif' font-size='42' font-weight='700' text-anchor='middle' fill='%230e0f13'%3Ea%3C/text%3E%3C/svg%3E"> <link rel="icon" href="icon.svg">
<link rel="manifest" href="manifest.webmanifest">
<link rel="stylesheet" href="style.css?v=14"> <link rel="stylesheet" href="style.css?v={{VERSION}}">
</head> </head>
<body> <body>
<header> <header>
<a class="logo" href="/">ani-web</a> <a class="logo" href="/">ani-web</a>
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false"> <form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
<input id="q" placeholder="search anime…" autocomplete="off" autocapitalize="off" spellcheck="false"> <input id="q" placeholder="search anime…" aria-label="search anime" autocomplete="off" autocapitalize="off" spellcheck="false">
</form> </form>
<span id="pill" class="pill"><i class="o"></i><i class="c"></i><span></span></span>
<span id="me" class="muted"></span> <span id="me" class="muted"></span>
<div id="menubox" style="display:none"> <div id="menubox" style="display:none">
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button> <button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
@@ -26,10 +31,10 @@
</header> </header>
<main id="main"><p class="muted">Search for an anime to get started.</p></main> <main id="main"><p class="muted">Search for an anime to get started.</p></main>
<div id="toasts" aria-live="polite"></div> <div id="toasts" aria-live="polite"></div>
<!-- ponytail: bump ?v= whenever app.js/style.css change — they are cached <!-- Versioning: ?v={{VERSION}} is replaced by the server (server/app.py index
max-age=300 in browser AND possibly in your HTTPS proxy, and a stale route) with the git SHA at startup — a correct cache key per commit.
copy is exactly how users keep seeing an old UI after a deploy --> No hand-bumping; JSV in app.js reads the same value from its own URL. -->
<script src="hls.min.js"></script> <script src="hls.min.js"></script>
<script src="app.js?v=14"></script> <script src="app.js?v={{VERSION}}"></script>
</body> </body>
</html> </html>
+14 -6
View File
@@ -36,6 +36,10 @@ h2 { margin: 0.4rem 0; }
display: flex; flex-direction: column; } display: flex; flex-direction: column; }
.card:hover { border-color: var(--acc); } .card:hover { border-color: var(--acc); }
.card img { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #000; display: block; } .card img { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #000; display: block; }
/* broken-poster fallback: a dead upstream URL must not leave a broken-image
icon in an otherwise fine card */
.ph-img { aspect-ratio: 2/3; background: var(--bd); }
.stat-anime .ph-img { width: 44px; border-radius: 6px; grid-row: span 2; }
.card-name { padding: 0.5rem; font-size: 0.85rem; color: var(--muted); } .card-name { padding: 0.5rem; font-size: 0.85rem; color: var(--muted); }
/* home: continue-watching hero + mini history cards */ /* home: continue-watching hero + mini history cards */
@@ -107,6 +111,7 @@ h2 { margin: 0.4rem 0; }
.hero-body h2 { white-space: normal; font-size: 1.05rem; } .hero-body h2 { white-space: normal; font-size: 1.05rem; }
.card.hist img { max-height: 150px; } .card.hist img { max-height: 150px; }
header #me { display: none; } header #me { display: none; }
header .pill span { display: none; }
} }
.detail-head { margin-bottom: 1rem; } .detail-head { margin-bottom: 1rem; }
@@ -158,13 +163,16 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); }
background: rgba(255, 255, 255, .1); } background: rgba(255, 255, 255, .1); }
.pl-buffer i { display: block; height: 100%; width: 0; .pl-buffer i { display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; } background: linear-gradient(90deg, var(--acc), #fbbf24); transition: width .25s ease; }
.pl-cdn { position: absolute; top: 10px; right: 10px; z-index: 2; /* header status pill: upstream site dot + CDN dot + short label */
display: flex; align-items: center; gap: .4rem; header .pill { display: flex; align-items: center; gap: .4rem; flex: none;
font: 600 .72rem/1 system-ui, sans-serif; padding: .3rem .55rem; border-radius: 999px; font: 600 .72rem/1 system-ui, sans-serif; padding: .3rem .55rem; border-radius: 999px;
background: rgba(0, 0, 0, .55); color: #fff; pointer-events: none; } border: 1px solid var(--bd); background: var(--card); color: var(--fg);
.pl-cdn i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; } cursor: help; }
.pl-cdn.ok i { background: #22c55e; } header .pill i { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #6b7280; }
.pl-cdn.degraded i { background: #f59e0b; } header .pill .o.ok, header .pill .c.ok { background: #22c55e; }
header .pill .c.degraded { background: #f59e0b; }
header .pill .o.down { background: #ef4444; }
header .pill.odown { border-color: #7f1d1d; }
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); #toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: .4rem; z-index: 9; } display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } } @keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }