Dracufeuer Codex commited on
Commit
ccd0d9f
·
verified ·
1 Parent(s): 939ef6c

fix(space): stabilize runtime

Browse files

Keep 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 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
- z-index: 4000 !important;
 
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 22rem at 50% 50%,
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 22rem at 50% 50%,
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(180deg, #05060a 0%, #111318 56%, #090a0d 100%);
1457
- color: #efe5bb;
1458
- opacity: 1;
1459
- visibility: visible;
1460
- transition: opacity 0.42s ease, visibility 0.42s ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 22rem at 50% 50%,
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 the benign asyncio loop-teardown noise."""
 
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 22rem at 50% 50%" in APP_HEAD
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 22rem at 50% 50%" in CRITICAL_HEAD
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 install_safe_asyncio_loop_del
 
 
 
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"]