Spaces:
Sleeping
Sleeping
fix(space): stabilize runtime
Browse filesKeep Safari dropdown menus above following rows. Show the Dota montage in the startup loader. Enable public share links, add agents.md, and quiet the Starlette 422 warning.
Co-authored-by: Codex <codex@openai.com>
- PLAN.md +5 -0
- agents.md +80 -0
- app.py +1 -0
- sitecustomize.py +15 -0
- src/dota2tuned/cli.py +1 -0
- src/dota2tuned/ui/gradio_app.py +116 -9
- src/dota2tuned/ui/runtime_hooks.py +32 -1
- tests/test_gradio_app.py +41 -2
- tests/test_runtime_hooks.py +23 -1
PLAN.md
CHANGED
|
@@ -161,6 +161,11 @@ All times are `America/Los_Angeles` / PDT unless noted.
|
|
| 161 |
- 2026-06-15 03:07: removed the duplicate landing-page DOTA2Tuned heading block from the assistant view while keeping the sidebar brand and tuned-model composer/result surface intact.
|
| 162 |
- 2026-06-15 03:12: aligned the assistant view with the rest of the app pages by dropping the centered hero-style layout and making the tuned-model surface full-width, top-aligned, compactly spaced, and flush with the existing app view grid.
|
| 163 |
- 2026-06-15 03:20: centered the startup-loader radial glow on the combined Dota icon and DOTA2Tuned text mark in both the critical first-paint fallback and hydrated loader, then changed the glow from inner Dota red `#ff6046` to outer Dota gold `#d9b166`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
## Adapter Eval Notes
|
| 166 |
|
|
|
|
| 161 |
- 2026-06-15 03:07: removed the duplicate landing-page DOTA2Tuned heading block from the assistant view while keeping the sidebar brand and tuned-model composer/result surface intact.
|
| 162 |
- 2026-06-15 03:12: aligned the assistant view with the rest of the app pages by dropping the centered hero-style layout and making the tuned-model surface full-width, top-aligned, compactly spaced, and flush with the existing app view grid.
|
| 163 |
- 2026-06-15 03:20: centered the startup-loader radial glow on the combined Dota icon and DOTA2Tuned text mark in both the critical first-paint fallback and hydrated loader, then changed the glow from inner Dota red `#ff6046` to outer Dota gold `#d9b166`.
|
| 164 |
+
- 2026-06-15 03:42: increased the centered startup-loader radial glow by 30%, from `22rem` to `28.6rem`, while keeping the red-to-gold gradient centered on the brand mark in both loader paths.
|
| 165 |
+
- 2026-06-15 04:02: diagnosed the Safari dropdown layering bug as a Gradio/Svelte stacking-chain issue: the fixed dropdown menu was nested under a `.dota-dropdown` block with `overflow: hidden` and a parent `.form` with `overflow: auto hidden`, so Safari could clip or paint the leftmost menu under later rows. Promoted the open dropdown plus its `.form`/`.row`/`.app-view` ancestors with temporary classes, forced the menu layer to fixed `z-index: 10000`, and kept ancestor overflow visible only while a menu is open.
|
| 166 |
+
- 2026-06-15 04:18: added the official Dota 2 homepage montage video as the startup-loader background, using the Steam WebM/MP4 sources with muted inline autoplay and keeping the red-to-gold radial glow plus dark vignette as an overlay/fallback.
|
| 167 |
+
- 2026-06-15 04:24: fixed the loader video visibility by changing the hydrated loader overlay from an opaque dark linear gradient to translucent RGBA layers and increasing the video layer opacity/brightness, so the montage remains visible behind the brand mark.
|
| 168 |
+
- 2026-06-15 04:34: enabled Gradio `share=True` in both Space and CLI launch paths, added a narrow startup warning filter for Gradio's deprecated Starlette 422 constant, and added root `agents.md` with Space/API instructions for coding agents.
|
| 169 |
|
| 170 |
## Adapter Eval Notes
|
| 171 |
|
agents.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DOTA2Tuned Agent Instructions
|
| 2 |
+
|
| 3 |
+
DOTA2Tuned is a Hugging Face Space for Dota 2 draft help, meta analysis,
|
| 4 |
+
counters, synergies, builds, match prediction, and grounded RAG answers.
|
| 5 |
+
|
| 6 |
+
Space URL: `https://huggingface.co/spaces/build-small-hackathon/dota2tuned`
|
| 7 |
+
|
| 8 |
+
Agents can fetch the live Space API instructions with:
|
| 9 |
+
|
| 10 |
+
```bash
|
| 11 |
+
curl https://huggingface.co/spaces/build-small-hackathon/dota2tuned/agents.md
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
## Python Client
|
| 15 |
+
|
| 16 |
+
Use `gradio_client` for calls:
|
| 17 |
+
|
| 18 |
+
```python
|
| 19 |
+
from gradio_client import Client
|
| 20 |
+
|
| 21 |
+
client = Client("build-small-hackathon/dota2tuned")
|
| 22 |
+
answer_html, evidence_json = client.predict(
|
| 23 |
+
"What should I pick with Crystal Maiden against Phantom Assassin?",
|
| 24 |
+
api_name="/tuned_model",
|
| 25 |
+
)
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Public Endpoints
|
| 29 |
+
|
| 30 |
+
- `/tuned_model`: free-form Dota 2 question. Returns HTML answer and JSON evidence.
|
| 31 |
+
- `/draft_coach`: draft recommendations. Inputs: allied hero IDs, enemy hero IDs,
|
| 32 |
+
banned hero IDs, role, and scope.
|
| 33 |
+
- `/hero_meta`: patch/meta lookup. Inputs: query string, optional hero ID, optional item key.
|
| 34 |
+
- `/match_predictor`: win-probability estimate. Inputs: Radiant hero IDs and Dire hero IDs.
|
| 35 |
+
- `/hero_builds`: item/build page for one hero ID.
|
| 36 |
+
- `/draft_lab`: compact draft card. Inputs: enemy hero IDs, role, mode.
|
| 37 |
+
- `/data_status`: current local data and artifact status.
|
| 38 |
+
|
| 39 |
+
## Calling Conventions
|
| 40 |
+
|
| 41 |
+
Use integer Dota hero IDs for hero dropdown inputs. Common examples:
|
| 42 |
+
|
| 43 |
+
- Anti-Mage: `1`
|
| 44 |
+
- Crystal Maiden: `5`
|
| 45 |
+
- Phantom Assassin: `44`
|
| 46 |
+
- Queen of Pain: `39`
|
| 47 |
+
- Shadow Fiend: `11`
|
| 48 |
+
|
| 49 |
+
Use these role values: `carry`, `mid`, `offlane`, `soft support`, `hard support`.
|
| 50 |
+
Use these scope values: `pro`, `high-rank`, `public`.
|
| 51 |
+
|
| 52 |
+
Example draft call:
|
| 53 |
+
|
| 54 |
+
```python
|
| 55 |
+
markdown, evidence_json = client.predict(
|
| 56 |
+
[5],
|
| 57 |
+
[44],
|
| 58 |
+
[],
|
| 59 |
+
"hard support",
|
| 60 |
+
"pro",
|
| 61 |
+
api_name="/draft_coach",
|
| 62 |
+
)
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
Example prediction call:
|
| 66 |
+
|
| 67 |
+
```python
|
| 68 |
+
prediction_json = client.predict(
|
| 69 |
+
[1, 2, 3, 25, 5],
|
| 70 |
+
[14, 74, 6, 26, 18],
|
| 71 |
+
api_name="/match_predictor",
|
| 72 |
+
)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Safety And Grounding
|
| 76 |
+
|
| 77 |
+
Prefer `/tuned_model` for natural-language questions because it returns evidence
|
| 78 |
+
alongside the answer. Treat generated advice as draft-support guidance, not a
|
| 79 |
+
guarantee of match outcome. Do not send API tokens, Steam credentials, or private
|
| 80 |
+
match data to the public Space.
|
app.py
CHANGED
|
@@ -46,5 +46,6 @@ if __name__ == "__main__":
|
|
| 46 |
head=APP_HEAD,
|
| 47 |
theme=gr.themes.Soft(),
|
| 48 |
ssr_mode=False,
|
|
|
|
| 49 |
app_kwargs=launch_app_kwargs(),
|
| 50 |
)
|
|
|
|
| 46 |
head=APP_HEAD,
|
| 47 |
theme=gr.themes.Soft(),
|
| 48 |
ssr_mode=False,
|
| 49 |
+
share=True,
|
| 50 |
app_kwargs=launch_app_kwargs(),
|
| 51 |
)
|
sitecustomize.py
CHANGED
|
@@ -10,6 +10,12 @@ from __future__ import annotations
|
|
| 10 |
import asyncio
|
| 11 |
import sys
|
| 12 |
import traceback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def _is_benign_loop_teardown(unraisable) -> bool:
|
|
@@ -36,6 +42,7 @@ def _is_benign_loop_teardown(unraisable) -> bool:
|
|
| 36 |
|
| 37 |
|
| 38 |
def _install_quiet_unraisablehook() -> None:
|
|
|
|
| 39 |
_install_safe_asyncio_loop_del()
|
| 40 |
if getattr(sys.unraisablehook, "_dota2tuned_quiet_unraisablehook", False):
|
| 41 |
return
|
|
@@ -71,4 +78,12 @@ def _install_safe_asyncio_loop_del() -> None:
|
|
| 71 |
asyncio.BaseEventLoop.__del__ = safe_loop_del
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
_install_quiet_unraisablehook()
|
|
|
|
| 10 |
import asyncio
|
| 11 |
import sys
|
| 12 |
import traceback
|
| 13 |
+
import warnings
|
| 14 |
+
|
| 15 |
+
_STARLETTE_422_DEPRECATION_PATTERN = (
|
| 16 |
+
r"'HTTP_422_UNPROCESSABLE_ENTITY' is deprecated\. "
|
| 17 |
+
r"Use 'HTTP_422_UNPROCESSABLE_CONTENT' instead\."
|
| 18 |
+
)
|
| 19 |
|
| 20 |
|
| 21 |
def _is_benign_loop_teardown(unraisable) -> bool:
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def _install_quiet_unraisablehook() -> None:
|
| 45 |
+
_install_quiet_starlette_422_warning()
|
| 46 |
_install_safe_asyncio_loop_del()
|
| 47 |
if getattr(sys.unraisablehook, "_dota2tuned_quiet_unraisablehook", False):
|
| 48 |
return
|
|
|
|
| 78 |
asyncio.BaseEventLoop.__del__ = safe_loop_del
|
| 79 |
|
| 80 |
|
| 81 |
+
def _install_quiet_starlette_422_warning() -> None:
|
| 82 |
+
warnings.filterwarnings(
|
| 83 |
+
"ignore",
|
| 84 |
+
message=_STARLETTE_422_DEPRECATION_PATTERN,
|
| 85 |
+
category=Warning,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
_install_quiet_unraisablehook()
|
src/dota2tuned/cli.py
CHANGED
|
@@ -226,6 +226,7 @@ def serve() -> None:
|
|
| 226 |
head=APP_HEAD,
|
| 227 |
theme=gr.themes.Soft(),
|
| 228 |
ssr_mode=False,
|
|
|
|
| 229 |
app_kwargs=launch_app_kwargs(),
|
| 230 |
)
|
| 231 |
|
|
|
|
| 226 |
head=APP_HEAD,
|
| 227 |
theme=gr.themes.Soft(),
|
| 228 |
ssr_mode=False,
|
| 229 |
+
share=True,
|
| 230 |
app_kwargs=launch_app_kwargs(),
|
| 231 |
)
|
| 232 |
|
src/dota2tuned/ui/gradio_app.py
CHANGED
|
@@ -847,11 +847,21 @@ __NAV_ICON_CSS__
|
|
| 847 |
}
|
| 848 |
.dota-dropdown {
|
| 849 |
position: relative;
|
|
|
|
| 850 |
z-index: 20;
|
| 851 |
}
|
| 852 |
.dota-dropdown:focus-within {
|
| 853 |
z-index: 2500;
|
| 854 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
.dota-dropdown .wrap,
|
| 856 |
.dota-dropdown .wrap-inner,
|
| 857 |
.dota-dropdown .input-container {
|
|
@@ -893,15 +903,19 @@ __NAV_ICON_CSS__
|
|
| 893 |
margin: 0 !important;
|
| 894 |
transform: translateY(-50%) !important;
|
| 895 |
background: transparent !important;
|
|
|
|
|
|
|
| 896 |
z-index: 2 !important;
|
| 897 |
}
|
| 898 |
.dota-dropdown .icon-wrap svg {
|
| 899 |
width: 16px !important;
|
| 900 |
height: 16px !important;
|
|
|
|
| 901 |
}
|
| 902 |
.dota-dropdown ul[role="listbox"],
|
| 903 |
.dota-dropdown .options {
|
| 904 |
-
|
|
|
|
| 905 |
pointer-events: auto !important;
|
| 906 |
min-width: 0 !important;
|
| 907 |
max-width: calc(100vw - 16px) !important;
|
|
@@ -1410,7 +1424,7 @@ html:not(.d2-ready) body::before {
|
|
| 1410 |
z-index: 2147483645;
|
| 1411 |
background:
|
| 1412 |
radial-gradient(
|
| 1413 |
-
circle
|
| 1414 |
rgba(255, 96, 70, 0.28) 0%,
|
| 1415 |
rgba(255, 96, 70, 0.20) 24%,
|
| 1416 |
rgba(217, 177, 102, 0.15) 52%,
|
|
@@ -1445,30 +1459,78 @@ html:not(.d2-ready) body::after {
|
|
| 1445 |
display: flex;
|
| 1446 |
align-items: center;
|
| 1447 |
justify-content: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1448 |
background:
|
| 1449 |
radial-gradient(
|
| 1450 |
-
circle
|
| 1451 |
rgba(255, 96, 70, 0.28) 0%,
|
| 1452 |
rgba(255, 96, 70, 0.20) 24%,
|
| 1453 |
rgba(217, 177, 102, 0.15) 52%,
|
| 1454 |
rgba(217, 177, 102, 0.00) 74%
|
| 1455 |
),
|
| 1456 |
-
linear-gradient(
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
| 1460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1461 |
}
|
| 1462 |
#d2-startup-loader.d2-loader-exit {
|
| 1463 |
opacity: 0;
|
| 1464 |
visibility: hidden;
|
| 1465 |
pointer-events: none;
|
| 1466 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1467 |
.d2-loader-brand {
|
|
|
|
|
|
|
| 1468 |
display: flex;
|
| 1469 |
align-items: center;
|
| 1470 |
gap: 14px;
|
| 1471 |
font-family: "Trajan Pro", "Goudy Trajan", "Noto Sans", serif;
|
|
|
|
| 1472 |
}
|
| 1473 |
.d2-loader-brand img {
|
| 1474 |
width: 48px;
|
|
@@ -1495,12 +1557,31 @@ html:not(.d2-ready) body::after {
|
|
| 1495 |
loader.setAttribute("role", "status");
|
| 1496 |
loader.setAttribute("aria-live", "polite");
|
| 1497 |
loader.innerHTML = `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1498 |
<div class="d2-loader-brand">
|
| 1499 |
<img src="${logoUrl}" alt="Dota 2" decoding="async">
|
| 1500 |
<strong>DOTA2Tuned</strong>
|
| 1501 |
</div>
|
| 1502 |
`;
|
| 1503 |
document.body.prepend(loader);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1504 |
};
|
| 1505 |
window.dota2tunedHideLoader = () => {
|
| 1506 |
const loader = document.getElementById("d2-startup-loader");
|
|
@@ -1582,7 +1663,7 @@ html:not(.d2-ready) body::before {
|
|
| 1582 |
z-index: 2147483645;
|
| 1583 |
background:
|
| 1584 |
radial-gradient(
|
| 1585 |
-
circle
|
| 1586 |
rgba(255, 96, 70, 0.28) 0%,
|
| 1587 |
rgba(255, 96, 70, 0.20) 24%,
|
| 1588 |
rgba(217, 177, 102, 0.15) 52%,
|
|
@@ -1693,8 +1774,33 @@ def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
|
| 1693 |
closestElement(target, '.options, [role="listbox"]')
|
| 1694 |
);
|
| 1695 |
}};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1696 |
const syncDropdownMenus = () => {{
|
| 1697 |
const margin = 8;
|
|
|
|
| 1698 |
if (!document.querySelector('.dota-dropdown .options, .dota-dropdown ul[role="listbox"]')) {{
|
| 1699 |
return;
|
| 1700 |
}}
|
|
@@ -1719,6 +1825,7 @@ def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
|
| 1719 |
listbox.style.left = `${{left}}px`;
|
| 1720 |
listbox.style.boxSizing = "border-box";
|
| 1721 |
listbox.style.overflowX = "hidden";
|
|
|
|
| 1722 |
}});
|
| 1723 |
}};
|
| 1724 |
const syncSidebarView = () => {{
|
|
|
|
| 847 |
}
|
| 848 |
.dota-dropdown {
|
| 849 |
position: relative;
|
| 850 |
+
overflow: visible !important;
|
| 851 |
z-index: 20;
|
| 852 |
}
|
| 853 |
.dota-dropdown:focus-within {
|
| 854 |
z-index: 2500;
|
| 855 |
}
|
| 856 |
+
.d2-dropdown-menu-host {
|
| 857 |
+
position: relative !important;
|
| 858 |
+
overflow: visible !important;
|
| 859 |
+
z-index: 3900 !important;
|
| 860 |
+
}
|
| 861 |
+
.dota-dropdown.d2-dropdown-open {
|
| 862 |
+
overflow: visible !important;
|
| 863 |
+
z-index: 5000 !important;
|
| 864 |
+
}
|
| 865 |
.dota-dropdown .wrap,
|
| 866 |
.dota-dropdown .wrap-inner,
|
| 867 |
.dota-dropdown .input-container {
|
|
|
|
| 903 |
margin: 0 !important;
|
| 904 |
transform: translateY(-50%) !important;
|
| 905 |
background: transparent !important;
|
| 906 |
+
cursor: pointer !important;
|
| 907 |
+
pointer-events: auto !important;
|
| 908 |
z-index: 2 !important;
|
| 909 |
}
|
| 910 |
.dota-dropdown .icon-wrap svg {
|
| 911 |
width: 16px !important;
|
| 912 |
height: 16px !important;
|
| 913 |
+
pointer-events: none !important;
|
| 914 |
}
|
| 915 |
.dota-dropdown ul[role="listbox"],
|
| 916 |
.dota-dropdown .options {
|
| 917 |
+
position: fixed !important;
|
| 918 |
+
z-index: 10000 !important;
|
| 919 |
pointer-events: auto !important;
|
| 920 |
min-width: 0 !important;
|
| 921 |
max-width: calc(100vw - 16px) !important;
|
|
|
|
| 1424 |
z-index: 2147483645;
|
| 1425 |
background:
|
| 1426 |
radial-gradient(
|
| 1427 |
+
circle 28.6rem at 50% 50%,
|
| 1428 |
rgba(255, 96, 70, 0.28) 0%,
|
| 1429 |
rgba(255, 96, 70, 0.20) 24%,
|
| 1430 |
rgba(217, 177, 102, 0.15) 52%,
|
|
|
|
| 1459 |
display: flex;
|
| 1460 |
align-items: center;
|
| 1461 |
justify-content: center;
|
| 1462 |
+
overflow: hidden;
|
| 1463 |
+
background: #05060a;
|
| 1464 |
+
color: #efe5bb;
|
| 1465 |
+
opacity: 1;
|
| 1466 |
+
visibility: visible;
|
| 1467 |
+
transition: opacity 0.42s ease, visibility 0.42s ease;
|
| 1468 |
+
}
|
| 1469 |
+
#d2-startup-loader::before {
|
| 1470 |
+
content: "";
|
| 1471 |
+
position: absolute;
|
| 1472 |
+
inset: 0;
|
| 1473 |
+
z-index: 1;
|
| 1474 |
background:
|
| 1475 |
radial-gradient(
|
| 1476 |
+
circle 28.6rem at 50% 50%,
|
| 1477 |
rgba(255, 96, 70, 0.28) 0%,
|
| 1478 |
rgba(255, 96, 70, 0.20) 24%,
|
| 1479 |
rgba(217, 177, 102, 0.15) 52%,
|
| 1480 |
rgba(217, 177, 102, 0.00) 74%
|
| 1481 |
),
|
| 1482 |
+
linear-gradient(
|
| 1483 |
+
180deg,
|
| 1484 |
+
rgba(5, 6, 10, 0.34) 0%,
|
| 1485 |
+
rgba(17, 19, 24, 0.16) 56%,
|
| 1486 |
+
rgba(9, 10, 13, 0.46) 100%
|
| 1487 |
+
);
|
| 1488 |
+
pointer-events: none;
|
| 1489 |
+
}
|
| 1490 |
+
#d2-startup-loader::after {
|
| 1491 |
+
content: "";
|
| 1492 |
+
position: absolute;
|
| 1493 |
+
inset: 0;
|
| 1494 |
+
z-index: 2;
|
| 1495 |
+
background:
|
| 1496 |
+
linear-gradient(
|
| 1497 |
+
90deg,
|
| 1498 |
+
rgba(5, 6, 10, 0.30),
|
| 1499 |
+
rgba(5, 6, 10, 0.08),
|
| 1500 |
+
rgba(5, 6, 10, 0.30)
|
| 1501 |
+
),
|
| 1502 |
+
linear-gradient(
|
| 1503 |
+
180deg,
|
| 1504 |
+
rgba(5, 6, 10, 0.36),
|
| 1505 |
+
rgba(5, 6, 10, 0.10) 44%,
|
| 1506 |
+
rgba(5, 6, 10, 0.54)
|
| 1507 |
+
);
|
| 1508 |
+
pointer-events: none;
|
| 1509 |
}
|
| 1510 |
#d2-startup-loader.d2-loader-exit {
|
| 1511 |
opacity: 0;
|
| 1512 |
visibility: hidden;
|
| 1513 |
pointer-events: none;
|
| 1514 |
}
|
| 1515 |
+
.d2-loader-video {
|
| 1516 |
+
position: absolute;
|
| 1517 |
+
inset: 0;
|
| 1518 |
+
z-index: 0;
|
| 1519 |
+
width: 100%;
|
| 1520 |
+
height: 100%;
|
| 1521 |
+
object-fit: cover;
|
| 1522 |
+
opacity: 0.72;
|
| 1523 |
+
filter: saturate(1.1) contrast(1.06) brightness(0.78);
|
| 1524 |
+
pointer-events: none;
|
| 1525 |
+
}
|
| 1526 |
.d2-loader-brand {
|
| 1527 |
+
position: relative;
|
| 1528 |
+
z-index: 3;
|
| 1529 |
display: flex;
|
| 1530 |
align-items: center;
|
| 1531 |
gap: 14px;
|
| 1532 |
font-family: "Trajan Pro", "Goudy Trajan", "Noto Sans", serif;
|
| 1533 |
+
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.72);
|
| 1534 |
}
|
| 1535 |
.d2-loader-brand img {
|
| 1536 |
width: 48px;
|
|
|
|
| 1557 |
loader.setAttribute("role", "status");
|
| 1558 |
loader.setAttribute("aria-live", "polite");
|
| 1559 |
loader.innerHTML = `
|
| 1560 |
+
<video
|
| 1561 |
+
class="d2-loader-video"
|
| 1562 |
+
autoplay
|
| 1563 |
+
muted
|
| 1564 |
+
loop
|
| 1565 |
+
playsinline
|
| 1566 |
+
preload="auto"
|
| 1567 |
+
aria-hidden="true"
|
| 1568 |
+
tabindex="-1"
|
| 1569 |
+
>
|
| 1570 |
+
<source type="video/webm" src="https://cdn.steamstatic.com/apps/dota2/videos/dota_react/homepage/dota_montage_webm.webm">
|
| 1571 |
+
<source type="video/mp4" src="https://cdn.steamstatic.com/apps/dota2/videos/dota_react/homepage/dota_montage_02.mp4">
|
| 1572 |
+
</video>
|
| 1573 |
<div class="d2-loader-brand">
|
| 1574 |
<img src="${logoUrl}" alt="Dota 2" decoding="async">
|
| 1575 |
<strong>DOTA2Tuned</strong>
|
| 1576 |
</div>
|
| 1577 |
`;
|
| 1578 |
document.body.prepend(loader);
|
| 1579 |
+
const video = loader.querySelector(".d2-loader-video");
|
| 1580 |
+
if (video) {
|
| 1581 |
+
video.muted = true;
|
| 1582 |
+
video.playsInline = true;
|
| 1583 |
+
video.play?.().catch(() => {});
|
| 1584 |
+
}
|
| 1585 |
};
|
| 1586 |
window.dota2tunedHideLoader = () => {
|
| 1587 |
const loader = document.getElementById("d2-startup-loader");
|
|
|
|
| 1663 |
z-index: 2147483645;
|
| 1664 |
background:
|
| 1665 |
radial-gradient(
|
| 1666 |
+
circle 28.6rem at 50% 50%,
|
| 1667 |
rgba(255, 96, 70, 0.28) 0%,
|
| 1668 |
rgba(255, 96, 70, 0.20) 24%,
|
| 1669 |
rgba(217, 177, 102, 0.15) 52%,
|
|
|
|
| 1774 |
closestElement(target, '.options, [role="listbox"]')
|
| 1775 |
);
|
| 1776 |
}};
|
| 1777 |
+
const clearDropdownMenuHosts = () => {{
|
| 1778 |
+
document
|
| 1779 |
+
.querySelectorAll(".d2-dropdown-open, .d2-dropdown-menu-host")
|
| 1780 |
+
.forEach((node) => {{
|
| 1781 |
+
node.classList.remove("d2-dropdown-open", "d2-dropdown-menu-host");
|
| 1782 |
+
}});
|
| 1783 |
+
}};
|
| 1784 |
+
const promoteDropdownMenu = (dropdown, listbox) => {{
|
| 1785 |
+
if (!dropdown || !listbox || !listbox.getClientRects().length) return;
|
| 1786 |
+
dropdown.classList.add("d2-dropdown-open");
|
| 1787 |
+
let node = dropdown.parentElement;
|
| 1788 |
+
while (node && node !== document.body) {{
|
| 1789 |
+
if (
|
| 1790 |
+
node.classList?.contains("form")
|
| 1791 |
+
|| node.classList?.contains("row")
|
| 1792 |
+
|| node.classList?.contains("app-view")
|
| 1793 |
+
|| node.classList?.contains("app-main")
|
| 1794 |
+
) {{
|
| 1795 |
+
node.classList.add("d2-dropdown-menu-host");
|
| 1796 |
+
}}
|
| 1797 |
+
if (node.classList?.contains("app-view")) break;
|
| 1798 |
+
node = node.parentElement;
|
| 1799 |
+
}}
|
| 1800 |
+
}};
|
| 1801 |
const syncDropdownMenus = () => {{
|
| 1802 |
const margin = 8;
|
| 1803 |
+
clearDropdownMenuHosts();
|
| 1804 |
if (!document.querySelector('.dota-dropdown .options, .dota-dropdown ul[role="listbox"]')) {{
|
| 1805 |
return;
|
| 1806 |
}}
|
|
|
|
| 1825 |
listbox.style.left = `${{left}}px`;
|
| 1826 |
listbox.style.boxSizing = "border-box";
|
| 1827 |
listbox.style.overflowX = "hidden";
|
| 1828 |
+
promoteDropdownMenu(dropdown, listbox);
|
| 1829 |
}});
|
| 1830 |
}};
|
| 1831 |
const syncSidebarView = () => {{
|
src/dota2tuned/ui/runtime_hooks.py
CHANGED
|
@@ -3,6 +3,12 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import sys
|
| 5 |
import traceback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def _is_benign_loop_teardown(unraisable) -> bool:
|
|
@@ -35,7 +41,8 @@ def _is_benign_loop_teardown(unraisable) -> bool:
|
|
| 35 |
|
| 36 |
|
| 37 |
def install_quiet_unraisablehook() -> None:
|
| 38 |
-
"""Silence only
|
|
|
|
| 39 |
install_safe_asyncio_loop_del()
|
| 40 |
if getattr(sys.unraisablehook, "_dota2tuned_quiet_unraisablehook", False):
|
| 41 |
return
|
|
@@ -72,6 +79,30 @@ def install_safe_asyncio_loop_del() -> None:
|
|
| 72 |
asyncio.BaseEventLoop.__del__ = safe_loop_del
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def close_idle_main_event_loop() -> None:
|
| 76 |
"""Close Gradio's orphaned main-thread loop before Uvicorn runs.
|
| 77 |
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import sys
|
| 5 |
import traceback
|
| 6 |
+
import warnings
|
| 7 |
+
|
| 8 |
+
STARLETTE_422_DEPRECATION_PATTERN = (
|
| 9 |
+
r"'HTTP_422_UNPROCESSABLE_ENTITY' is deprecated\. "
|
| 10 |
+
r"Use 'HTTP_422_UNPROCESSABLE_CONTENT' instead\."
|
| 11 |
+
)
|
| 12 |
|
| 13 |
|
| 14 |
def _is_benign_loop_teardown(unraisable) -> bool:
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def install_quiet_unraisablehook() -> None:
|
| 44 |
+
"""Silence only known benign Gradio/Starlette runtime noise."""
|
| 45 |
+
install_quiet_starlette_422_warning()
|
| 46 |
install_safe_asyncio_loop_del()
|
| 47 |
if getattr(sys.unraisablehook, "_dota2tuned_quiet_unraisablehook", False):
|
| 48 |
return
|
|
|
|
| 79 |
asyncio.BaseEventLoop.__del__ = safe_loop_del
|
| 80 |
|
| 81 |
|
| 82 |
+
def install_quiet_starlette_422_warning() -> None:
|
| 83 |
+
"""Suppress Gradio's deprecated Starlette 422 constant warning.
|
| 84 |
+
|
| 85 |
+
Gradio 6.18 still references Starlette's old
|
| 86 |
+
HTTP_422_UNPROCESSABLE_ENTITY constant. Starlette emits a deprecation
|
| 87 |
+
warning for that exact symbol. Define the old alias before Gradio imports
|
| 88 |
+
so Starlette's deprecation `__getattr__` path is not hit; keep the narrow
|
| 89 |
+
warning filter as a fallback for already-imported modules.
|
| 90 |
+
"""
|
| 91 |
+
warnings.filterwarnings(
|
| 92 |
+
"ignore",
|
| 93 |
+
message=STARLETTE_422_DEPRECATION_PATTERN,
|
| 94 |
+
category=Warning,
|
| 95 |
+
)
|
| 96 |
+
try:
|
| 97 |
+
from starlette import status
|
| 98 |
+
except Exception:
|
| 99 |
+
return
|
| 100 |
+
if "HTTP_422_UNPROCESSABLE_ENTITY" not in vars(status):
|
| 101 |
+
status.HTTP_422_UNPROCESSABLE_ENTITY = getattr(
|
| 102 |
+
status, "HTTP_422_UNPROCESSABLE_CONTENT", 422
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
def close_idle_main_event_loop() -> None:
|
| 107 |
"""Close Gradio's orphaned main-thread loop before Uvicorn runs.
|
| 108 |
|
tests/test_gradio_app.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import inspect
|
|
|
|
| 2 |
from types import SimpleNamespace
|
| 3 |
|
| 4 |
import gradio as gr
|
|
@@ -239,12 +240,22 @@ def test_css_keeps_dropdown_chevrons_inside_fields():
|
|
| 239 |
assert ".dota-dropdown .icon-wrap" in APP_CSS
|
| 240 |
assert "right: 8px !important" in APP_CSS
|
| 241 |
assert "padding-right: 32px !important" in APP_CSS
|
|
|
|
|
|
|
| 242 |
assert ".dota-dropdown .secondary-wrapper" in APP_CSS
|
| 243 |
assert "background: transparent !important" in APP_CSS
|
| 244 |
assert ".hero-dropdown input[autocomplete=\"off\"]" not in APP_CSS
|
| 245 |
assert "min-width: 100% !important" not in APP_CSS
|
| 246 |
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
def test_css_shows_full_selected_hero_cards():
|
| 249 |
hero_image_css = APP_CSS.split(".hero-card > img", 1)[1].split("}", 1)[0]
|
| 250 |
|
|
@@ -277,6 +288,14 @@ def test_loader_and_sidebar_logo_use_shared_red_flash():
|
|
| 277 |
|
| 278 |
assert "d2-logo-red-flash" in APP_HEAD
|
| 279 |
assert "#d2-startup-loader" in APP_HEAD
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
assert "dota2tunedHideLoader" in APP_HEAD
|
| 281 |
assert "dota2tunedRevealWhenReady" in APP_HEAD
|
| 282 |
assert "DOTA2Tuned" in APP_HEAD
|
|
@@ -287,10 +306,13 @@ def test_loader_and_sidebar_logo_use_shared_red_flash():
|
|
| 287 |
assert "html.d2-ready .gradio-container" in APP_HEAD
|
| 288 |
assert "html:not(.d2-ready) body::before" in APP_HEAD
|
| 289 |
assert "html:not(.d2-ready) body::after" in APP_HEAD
|
| 290 |
-
assert "circle
|
| 291 |
assert "circle at 50% 42%" not in APP_HEAD
|
| 292 |
assert "rgba(255, 96, 70, 0.28) 0%" in APP_HEAD
|
| 293 |
assert "rgba(217, 177, 102, 0.15) 52%" in APP_HEAD
|
|
|
|
|
|
|
|
|
|
| 294 |
assert "waitForMountedImages" in APP_HEAD
|
| 295 |
assert "document.fonts?.ready" in APP_HEAD
|
| 296 |
assert "25%, 75%" in logo_keyframes
|
|
@@ -386,6 +408,18 @@ def test_dropdown_js_refreshes_reused_search_option_nodes():
|
|
| 386 |
assert "characterData: true" in js
|
| 387 |
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
def test_app_head_preconnects_without_theme_redirect():
|
| 390 |
assert "preconnect" in APP_HEAD
|
| 391 |
assert "cdn.steamstatic.com" in APP_HEAD
|
|
@@ -420,7 +454,7 @@ def test_critical_head_hides_app_before_bundle_mounts():
|
|
| 420 |
assert 'id="d2-critical"' in CRITICAL_HEAD
|
| 421 |
assert "html:not(.d2-ready) .gradio-container" in CRITICAL_HEAD
|
| 422 |
assert "visibility: hidden !important" in CRITICAL_HEAD
|
| 423 |
-
assert "circle
|
| 424 |
assert "circle at 50% 42%" not in CRITICAL_HEAD
|
| 425 |
assert "rgba(217, 177, 102, 0.15) 52%" in CRITICAL_HEAD
|
| 426 |
assert 'classList.add("d2-loading")' in CRITICAL_HEAD
|
|
@@ -432,6 +466,11 @@ def test_launch_app_kwargs_wires_critical_head_middleware():
|
|
| 432 |
assert any(getattr(m, "cls", None) is _CriticalHeadMiddleware for m in middleware)
|
| 433 |
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
def _drive_middleware(downstream):
|
| 436 |
import asyncio
|
| 437 |
|
|
|
|
| 1 |
import inspect
|
| 2 |
+
from pathlib import Path
|
| 3 |
from types import SimpleNamespace
|
| 4 |
|
| 5 |
import gradio as gr
|
|
|
|
| 240 |
assert ".dota-dropdown .icon-wrap" in APP_CSS
|
| 241 |
assert "right: 8px !important" in APP_CSS
|
| 242 |
assert "padding-right: 32px !important" in APP_CSS
|
| 243 |
+
assert "pointer-events: auto !important" in APP_CSS
|
| 244 |
+
assert "cursor: pointer !important" in APP_CSS
|
| 245 |
assert ".dota-dropdown .secondary-wrapper" in APP_CSS
|
| 246 |
assert "background: transparent !important" in APP_CSS
|
| 247 |
assert ".hero-dropdown input[autocomplete=\"off\"]" not in APP_CSS
|
| 248 |
assert "min-width: 100% !important" not in APP_CSS
|
| 249 |
|
| 250 |
|
| 251 |
+
def test_css_promotes_open_dropdown_stack_for_safari():
|
| 252 |
+
assert ".d2-dropdown-menu-host" in APP_CSS
|
| 253 |
+
assert ".dota-dropdown.d2-dropdown-open" in APP_CSS
|
| 254 |
+
assert "overflow: visible !important" in APP_CSS
|
| 255 |
+
assert "position: fixed !important" in APP_CSS
|
| 256 |
+
assert "z-index: 10000 !important" in APP_CSS
|
| 257 |
+
|
| 258 |
+
|
| 259 |
def test_css_shows_full_selected_hero_cards():
|
| 260 |
hero_image_css = APP_CSS.split(".hero-card > img", 1)[1].split("}", 1)[0]
|
| 261 |
|
|
|
|
| 288 |
|
| 289 |
assert "d2-logo-red-flash" in APP_HEAD
|
| 290 |
assert "#d2-startup-loader" in APP_HEAD
|
| 291 |
+
assert "d2-loader-video" in APP_HEAD
|
| 292 |
+
assert "dota_montage_webm.webm" in APP_HEAD
|
| 293 |
+
assert "dota_montage_02.mp4" in APP_HEAD
|
| 294 |
+
assert "autoplay" in APP_HEAD
|
| 295 |
+
assert "muted" in APP_HEAD
|
| 296 |
+
assert "loop" in APP_HEAD
|
| 297 |
+
assert "playsinline" in APP_HEAD
|
| 298 |
+
assert "video.play?.().catch" in APP_HEAD
|
| 299 |
assert "dota2tunedHideLoader" in APP_HEAD
|
| 300 |
assert "dota2tunedRevealWhenReady" in APP_HEAD
|
| 301 |
assert "DOTA2Tuned" in APP_HEAD
|
|
|
|
| 306 |
assert "html.d2-ready .gradio-container" in APP_HEAD
|
| 307 |
assert "html:not(.d2-ready) body::before" in APP_HEAD
|
| 308 |
assert "html:not(.d2-ready) body::after" in APP_HEAD
|
| 309 |
+
assert "circle 28.6rem at 50% 50%" in APP_HEAD
|
| 310 |
assert "circle at 50% 42%" not in APP_HEAD
|
| 311 |
assert "rgba(255, 96, 70, 0.28) 0%" in APP_HEAD
|
| 312 |
assert "rgba(217, 177, 102, 0.15) 52%" in APP_HEAD
|
| 313 |
+
assert "rgba(17, 19, 24, 0.16) 56%" in APP_HEAD
|
| 314 |
+
assert "opacity: 0.72" in APP_HEAD
|
| 315 |
+
assert "brightness(0.78)" in APP_HEAD
|
| 316 |
assert "waitForMountedImages" in APP_HEAD
|
| 317 |
assert "document.fonts?.ready" in APP_HEAD
|
| 318 |
assert "25%, 75%" in logo_keyframes
|
|
|
|
| 408 |
assert "characterData: true" in js
|
| 409 |
|
| 410 |
|
| 411 |
+
def test_dropdown_js_marks_open_menu_ancestors_for_safari():
|
| 412 |
+
js = _dropdown_js({})
|
| 413 |
+
|
| 414 |
+
assert "clearDropdownMenuHosts" in js
|
| 415 |
+
assert "promoteDropdownMenu" in js
|
| 416 |
+
assert "d2-dropdown-open" in js
|
| 417 |
+
assert "d2-dropdown-menu-host" in js
|
| 418 |
+
assert 'classList?.contains("form")' in js
|
| 419 |
+
assert 'classList?.contains("row")' in js
|
| 420 |
+
assert 'classList?.contains("app-view")' in js
|
| 421 |
+
|
| 422 |
+
|
| 423 |
def test_app_head_preconnects_without_theme_redirect():
|
| 424 |
assert "preconnect" in APP_HEAD
|
| 425 |
assert "cdn.steamstatic.com" in APP_HEAD
|
|
|
|
| 454 |
assert 'id="d2-critical"' in CRITICAL_HEAD
|
| 455 |
assert "html:not(.d2-ready) .gradio-container" in CRITICAL_HEAD
|
| 456 |
assert "visibility: hidden !important" in CRITICAL_HEAD
|
| 457 |
+
assert "circle 28.6rem at 50% 50%" in CRITICAL_HEAD
|
| 458 |
assert "circle at 50% 42%" not in CRITICAL_HEAD
|
| 459 |
assert "rgba(217, 177, 102, 0.15) 52%" in CRITICAL_HEAD
|
| 460 |
assert 'classList.add("d2-loading")' in CRITICAL_HEAD
|
|
|
|
| 466 |
assert any(getattr(m, "cls", None) is _CriticalHeadMiddleware for m in middleware)
|
| 467 |
|
| 468 |
|
| 469 |
+
def test_launch_entrypoints_enable_public_share_links():
|
| 470 |
+
assert "share=True" in Path("app.py").read_text()
|
| 471 |
+
assert "share=True" in Path("src/dota2tuned/cli.py").read_text()
|
| 472 |
+
|
| 473 |
+
|
| 474 |
def _drive_middleware(downstream):
|
| 475 |
import asyncio
|
| 476 |
|
tests/test_runtime_hooks.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
| 1 |
import asyncio
|
|
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
-
from dota2tuned.ui.runtime_hooks import
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def test_install_safe_asyncio_loop_del_suppresses_only_invalid_fd(monkeypatch):
|
|
@@ -28,3 +32,21 @@ def test_install_safe_asyncio_loop_del_preserves_other_value_errors(monkeypatch)
|
|
| 28 |
|
| 29 |
with pytest.raises(ValueError, match="not the Gradio fd noise"):
|
| 30 |
asyncio.BaseEventLoop.__del__(object())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import warnings
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
+
from dota2tuned.ui.runtime_hooks import (
|
| 7 |
+
install_quiet_starlette_422_warning,
|
| 8 |
+
install_safe_asyncio_loop_del,
|
| 9 |
+
)
|
| 10 |
|
| 11 |
|
| 12 |
def test_install_safe_asyncio_loop_del_suppresses_only_invalid_fd(monkeypatch):
|
|
|
|
| 32 |
|
| 33 |
with pytest.raises(ValueError, match="not the Gradio fd noise"):
|
| 34 |
asyncio.BaseEventLoop.__del__(object())
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_install_quiet_starlette_422_warning_suppresses_only_target_message():
|
| 38 |
+
with warnings.catch_warnings(record=True) as caught:
|
| 39 |
+
warnings.simplefilter("always")
|
| 40 |
+
install_quiet_starlette_422_warning()
|
| 41 |
+
import starlette.status
|
| 42 |
+
|
| 43 |
+
assert starlette.status.HTTP_422_UNPROCESSABLE_ENTITY == 422
|
| 44 |
+
warnings.warn(
|
| 45 |
+
"'HTTP_422_UNPROCESSABLE_ENTITY' is deprecated. "
|
| 46 |
+
"Use 'HTTP_422_UNPROCESSABLE_CONTENT' instead.",
|
| 47 |
+
UserWarning,
|
| 48 |
+
stacklevel=2,
|
| 49 |
+
)
|
| 50 |
+
warnings.warn("other warning", UserWarning, stacklevel=2)
|
| 51 |
+
|
| 52 |
+
assert [str(item.message) for item in caught] == ["other warning"]
|