feat: add hover-intent prefetch for Open Deck button (WEB_PREFETCH=1) (#68)

This commit is contained in:
mwisnowski 2026-04-01 20:54:51 -07:00 committed by GitHub
parent 1f01d8b493
commit e8b8fab3f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 138 additions and 5 deletions

View file

@ -129,6 +129,7 @@ WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures →
# Ideals UI Mode
WEB_IDEALS_UI=slider # input|slider. 'slider' (default): range sliders with live value display. 'input': text input boxes
WEB_PREFETCH=0 # 1=enable hover-intent prefetch on key nav targets (e.g. Open Deck); respects Data Saver
# Tagging Refinement Feature Flags
TAG_NORMALIZE_KEYWORDS=1 # dockerhub: TAG_NORMALIZE_KEYWORDS="1" # Normalize keywords & filter specialty mechanics

View file

@ -8,7 +8,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Link PRs/issues inline when helpful, e.g., (#123) or [#123]. Reference-style links at the bottom are encouraged for readability.
## [Unreleased]
_No unreleased changes yet._
### Added
- **Hover-intent prefetch** (`WEB_PREFETCH=1`): Hovering over an "Open" button on the Finished Decks page now prefetches the deck view in the background after a 100 ms delay, eliminating the CSV-parse wait on click. On Chrome 108+, uses the Speculation Rules API for full prerender (`data-prerender-ok="1"`); falls back to `rel=prefetch` on other browsers. Feature-flagged and off by default; respects Data Saver / 2G connections and limits concurrent prefetches to 2.
## [4.5.1] - 2026-04-01
### Added

View file

@ -300,6 +300,7 @@ See `.env.example` for the full catalog. Common knobs:
| `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). |
| `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). |
| `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). |
| `WEB_PREFETCH` | `0` | Enable hover-intent prefetch on key navigation targets (e.g. the Open button on Finished Decks). Requires `1` to activate; respects Data Saver / slow connections. |
| `ENABLE_CARD_DETAILS` | `0` | Show card detail pages with similar card recommendations at `/cards/<name>`. |
| `SIMILARITY_CACHE_ENABLED` | `1` | Use pre-computed similarity cache for fast card detail pages. |
| `ENABLE_BATCH_BUILD` | `1` | Enable Build X and Compare feature (build multiple decks in parallel and compare results). |

View file

@ -1,4 +1,6 @@
# MTG Python Deckbuilder
## [Unreleased]
_No unreleased changes yet._
### Added
- **Hover-intent prefetch** (`WEB_PREFETCH=1`): Hovering over an "Open" button on the Finished Decks page now prefetches the deck view in the background after a 100 ms delay, eliminating the CSV-parse wait on click. On Chrome 108+, uses the Speculation Rules API for full prerender (`data-prerender-ok="1"`); falls back to `rel=prefetch` on other browsers. Feature-flagged and off by default; respects Data Saver / 2G connections and limits concurrent prefetches to 2.

View file

@ -234,6 +234,7 @@ RATE_LIMIT_SUGGEST = _as_int(os.getenv("RANDOM_RATE_LIMIT_SUGGEST"), 30)
RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False)
RANDOM_REROLL_THROTTLE_MS = _as_int(os.getenv("RANDOM_REROLL_THROTTLE_MS"), 350)
USER_THEME_LIMIT = _as_int(os.getenv("USER_THEME_LIMIT"), 8)
ENABLE_PREFETCH = _as_bool(os.getenv("WEB_PREFETCH"), False)
_THEME_MODE_ENV = (os.getenv("THEME_MATCH_MODE") or "").strip().lower()
DEFAULT_THEME_MATCH_MODE = "strict" if _THEME_MODE_ENV in {"strict", "s"} else "permissive"
@ -364,6 +365,7 @@ templates.env.globals.update({
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
"user_theme_limit": USER_THEME_LIMIT,
"default_theme_match_mode": DEFAULT_THEME_MATCH_MODE,
"prefetch_enabled": ENABLE_PREFETCH,
})
# Expose catalog hash (for cache versioning / service worker) best-effort, fallback to 'dev'

View file

@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
from ..app import templates
from ..services.orchestrator import tags_for_commander
from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx
from ..app import ENABLE_BUDGET_MODE
from ..app import ENABLE_BUDGET_MODE, ENABLE_PREFETCH
router = APIRouter(prefix="/decks")
@ -445,7 +445,10 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
except Exception:
pass
return templates.TemplateResponse("decks/view.html", ctx)
resp = templates.TemplateResponse("decks/view.html", ctx)
if ENABLE_PREFETCH:
resp.headers["Cache-Control"] = "private, max-age=30, must-revalidate"
return resp
@router.get("/compare", response_class=HTMLResponse)

View file

@ -0,0 +1,118 @@
/**
* prefetch-hover.ts Hover-intent prefetch/prerender for key navigation targets.
*
* Enabled server-side via WEB_PREFETCH=1. Elements opt in with:
* data-prefetch="1" enable prefetch on this element
* data-prefetch-url="<url>" URL to prefetch (falls back to el.href)
* data-prerender-ok="1" allow Chrome Speculation Rules prerender
* (only safe GET routes with no side effects)
*
* Strategy selection (per element):
* data-prerender-ok="1" + Chrome Speculation Rules support prerender
* otherwise rel=prefetch
*
* Progressive enhancement: degrades gracefully when unsupported.
* Respects navigator.connection.saveData and slow (2G) effective connections.
*/
(function () {
'use strict';
const MAX_CONCURRENT = 2;
const MAX_PRERENDERS = 2;
const DELAY_MS = 100;
let _inflight = 0;
const _prefetched: Record<string, boolean> = {};
// Speculation Rules API detection (Chrome 108+)
const _supportsSpeculation: boolean = (function () {
try {
return typeof HTMLScriptElement !== 'undefined' &&
'supports' in HTMLScriptElement &&
typeof (HTMLScriptElement as any).supports === 'function' &&
(HTMLScriptElement as any).supports('speculationrules');
} catch (_) { return false; }
})();
let _speculationEl: HTMLScriptElement | null = null;
const _prerenderQueued: string[] = [];
function _saverMode(): boolean {
try {
const conn: any = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection || {};
if (conn.saveData === true) return true;
const et: string = conn.effectiveType || '';
return et === '2g' || et === 'slow-2g';
} catch (_) { return false; }
}
/** Inject/update a single <script type="speculationrules"> for prerender. */
function _addSpeculationPrerender(url: string): void {
if (_prerenderQueued.indexOf(url) !== -1) return;
// Cap queued prerenders to avoid excess memory
if (_prerenderQueued.length >= MAX_PRERENDERS) {
_prerenderQueued.shift();
}
_prerenderQueued.push(url);
const rules = { prerender: [{ source: 'list', urls: _prerenderQueued.slice(), eagerness: 'immediate' }] };
if (!_speculationEl) {
_speculationEl = document.createElement('script');
_speculationEl.type = 'speculationrules';
document.head.appendChild(_speculationEl);
}
_speculationEl.textContent = JSON.stringify(rules);
}
function _injectPrefetch(url: string): void {
if (_prefetched[url]) return;
if (_inflight >= MAX_CONCURRENT) return;
_prefetched[url] = true;
_inflight++;
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
link.as = 'document';
link.addEventListener('load', function () { if (_inflight > 0) _inflight--; });
link.addEventListener('error', function () { if (_inflight > 0) _inflight--; });
document.head.appendChild(link);
}
const _wired: WeakSet<Element> = new WeakSet();
function _attach(el: Element): void {
if (_wired.has(el)) return;
_wired.add(el);
let timer: ReturnType<typeof setTimeout> | null = null;
el.addEventListener('mouseenter', function () {
if (_saverMode()) return;
const url = (el as HTMLElement).getAttribute('data-prefetch-url') || (el as HTMLAnchorElement).href || '';
if (!url) return;
const prerenderOk = (el as HTMLElement).getAttribute('data-prerender-ok') === '1';
timer = setTimeout(function () {
timer = null;
if (_saverMode()) return;
if (_supportsSpeculation && prerenderOk) {
_addSpeculationPrerender(url);
} else {
if (_inflight >= MAX_CONCURRENT) return;
_injectPrefetch(url);
}
}, DELAY_MS);
});
el.addEventListener('mouseleave', function () {
if (timer !== null) { clearTimeout(timer); timer = null; }
});
}
function _init(): void {
document.querySelectorAll('[data-prefetch="1"]').forEach(_attach);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
}
// Re-scan after HTMX partial updates so dynamically-added elements are wired
document.addEventListener('htmx:afterSettle', _init);
})();

View file

@ -399,6 +399,9 @@
{% endif %}
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
<!-- Hover card panel system moved to cardHover.ts -->
{% if prefetch_enabled %}
<script src="/static/js/prefetch-hover.js?v=20260401-1"></script>
{% endif %}
<!-- Price tooltip: lightweight fetch on mouseenter for .card-name-price-hover -->
<script>
(function(){

View file

@ -76,7 +76,7 @@
{% endif %}
<form action="/decks/view" method="get" style="display:inline; margin:0;">
<input type="hidden" name="name" value="{{ it.name }}" />
<button type="submit" aria-label="Open deck {{ it.commander }}">Open</button>
<button type="submit" aria-label="Open deck {{ it.commander }}"{% if prefetch_enabled %} data-prefetch="1" data-prefetch-url="/decks/view?name={{ it.name|urlencode }}"{% endif %}>Open</button>
</form>
</div>
</div>

View file

@ -129,6 +129,7 @@ services:
# Ideals UI Mode
WEB_IDEALS_UI: "slider" # input|slider. 'slider' (default): range sliders. 'input': text boxes
WEB_PREFETCH: "1" # 1=enable hover-intent prefetch on key navigation targets (e.g. Open Deck)
# Tagging Refinement Feature Flags
TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended)

View file

@ -131,6 +131,7 @@ services:
# Ideals UI Mode
WEB_IDEALS_UI: "slider" # input|slider. 'slider' (default): range sliders. 'input': text boxes
WEB_PREFETCH: "0" # 1=enable hover-intent prefetch on key navigation targets (e.g. Open Deck)
# Tagging Refinement Feature Flags
TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended)