overhaul: migrated basic JavaScript to TypeScript, began consolidation efforts

This commit is contained in:
matt 2025-10-28 16:17:55 -07:00
parent b994978f60
commit 6a94b982cb
15 changed files with 2012 additions and 74 deletions

View file

@ -19,6 +19,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Interactive examples of all buttons, modals, forms, cards, and panels
- Jinja2 macros for consistent component usage
- Component partial templates for reuse across pages
- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety
- Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript
- Created shared type definitions for state management, telemetry, HTMX, and UI components
- Integrated TypeScript compilation into build process (`npm run build:ts`)
- Compiled JavaScript output in `code/web/static/js/` directory
- Docker build automatically compiles TypeScript during image creation
### Changed
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
@ -26,6 +32,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- PostCSS build pipeline with autoprefixer
- Reduced inline styles in templates (moved to shared CSS classes)
- Organized CSS into functional sections with clear documentation
- **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast
- **JavaScript Modernization**: Updated to modern JavaScript patterns
- Converted `var` declarations to `const`/`let`
- Added TypeScript type annotations for better IDE support and error catching
- Consolidated event handlers and utility functions
- **Docker Build Optimization**: Improved developer experience
- Hot reload enabled for templates and static files
- Volume mounts for rapid iteration without rebuilds

View file

@ -3,7 +3,7 @@
## [Unreleased]
### Summary
Web UI improvements with Tailwind CSS migration, component library, and optional card image caching for faster performance.
Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, and optional card image caching for faster performance and better maintainability.
### Added
- **Card Image Caching**: Optional local image cache for faster card display
@ -15,14 +15,24 @@ Web UI improvements with Tailwind CSS migration, component library, and optional
- Interactive examples of all UI components
- Reusable Jinja2 macros for consistent design
- Component partial templates for reuse across pages
- **TypeScript Support**: Migrated JavaScript to TypeScript for better code quality
- Type definitions for state management, telemetry, and UI components
- Improved IDE support with autocomplete and type checking
- Integrated into build process (compiles during Docker build)
### Changed
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
- Tailwind CSS v3 with custom MTG color palette
- PostCSS build pipeline with autoprefixer
- Minimized inline styles in favor of shared CSS classes
- **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast
- **JavaScript Modernization**: Updated to modern JavaScript patterns
- Converted to TypeScript for better type safety
- Replaced `var` with `const`/`let` throughout
- Improved error handling and code organization
- **Docker Build Optimization**: Improved developer experience
- Hot reload for templates and CSS (no rebuild needed)
- TypeScript compilation integrated into build process
- **Template Modernization**: Migrated templates to use component system
### Removed
@ -35,11 +45,19 @@ _None_
- Hot reload for CSS/template changes (no Docker rebuild needed)
- Optional image caching reduces Scryfall API calls
- Faster page loads with optimized CSS
- TypeScript compilation produces optimized JavaScript
### For Users
- Faster card image loading with optional caching
- Cleaner, more consistent web UI design
- Improved page load performance
- More reliable JavaScript behavior
### For Developers
- TypeScript provides better IDE support and error detection
- Clear type definitions for all JavaScript utilities
- Easier onboarding with typed interfaces
- Automated build process handles TypeScript compilation
### Deprecated
_None_

View file

@ -1108,6 +1108,8 @@ async def build_index(request: Request) -> HTMLResponse:
if q_commander:
# Persist a human-friendly commander name into session for the wizard
sess["commander"] = str(q_commander)
# Set flag to indicate this is a quick-build scenario
sess["quick_build"] = True
except Exception:
pass
return_url = None
@ -1147,12 +1149,17 @@ async def build_index(request: Request) -> HTMLResponse:
last_step = 2
else:
last_step = 1
# Only pass commander to template if coming from commander browser (?commander= query param)
# This prevents stale commander from being pre-filled on subsequent builds
# The query param only exists on initial navigation from commander browser
should_auto_fill = q_commander is not None
resp = templates.TemplateResponse(
request,
"build/index.html",
{
"sid": sid,
"commander": sess.get("commander"),
"commander": sess.get("commander") if should_auto_fill else None,
"tags": sess.get("tags", []),
"name": sess.get("custom_export_base"),
"last_step": last_step,
@ -1350,7 +1357,12 @@ async def build_new_modal(request: Request) -> HTMLResponse:
for key in skip_keys:
sess.pop(key, None)
# M2: Clear commander and form selections for fresh start
# M2: Check if this is a quick-build scenario (from commander browser)
# Use the quick_build flag set by /build route when ?commander= param present
is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag
# M2: Clear commander and form selections for fresh start (unless quick build)
if not is_quick_build:
commander_keys = [
"commander", "partner", "background", "commander_mode",
"themes", "bracket"
@ -1370,6 +1382,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
"enable_batch_build": ENABLE_BATCH_BUILD,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"form": {
"commander": sess.get("commander", ""), # Pre-fill for quick-build
"prefer_combos": bool(sess.get("prefer_combos")),
"combo_count": sess.get("combo_target_count"),
"combo_balance": sess.get("combo_balance"),

View file

@ -1113,19 +1113,31 @@ video {
[data-theme="light-blend"]{
--bg: #e8e2d0;
/* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
--panel: #ffffff;
/* crisp panels for readability */
--text: #0b0d12;
/* warm beige background (keep existing) */
--panel: #ebe5d8;
/* lighter warm cream - more contrast with bg, subtle panels */
--text: #1a1410;
/* dark brown for readability */
--muted: #6b655d;
/* slightly warm muted */
--border: #d6d1c7;
/* neutral warm-gray border */
/* Slightly darker banner/sidebar for separation */
--surface-banner: #1a1b1e;
--surface-sidebar: #1a1b1e;
--surface-banner-text: #e8e8e8;
--surface-sidebar-text: #e8e8e8;
/* warm muted brown (keep existing) */
--border: #bfb5a3;
/* darker warm-gray border for better definition */
/* Navbar/banner: darker warm brown for hierarchy */
--surface-banner: #9b8f7a;
/* warm medium brown - darker than panels, lighter than dark theme */
--surface-sidebar: #9b8f7a;
/* match banner for consistency */
--surface-banner-text: #1a1410;
/* dark brown text on medium brown bg */
--surface-sidebar-text: #1a1410;
/* dark brown text on medium brown bg */
/* Button colors: use taupe for buttons so they stand out from light panels */
--btn-bg: #d4cbb8;
/* medium warm taupe - stands out against light panels */
--btn-text: #1a1410;
/* dark brown text */
--btn-hover-bg: #c4b9a5;
/* darker taupe on hover */
}
[data-theme="dark"]{
@ -1880,23 +1892,25 @@ small, .muted{
/* Home page darker buttons */
.home-button.btn-secondary {
background: #1a1d24;
border-color: #2a2d35;
background: var(--btn-bg, #1a1d24);
color: var(--btn-text, #e8e8e8);
border-color: var(--border);
}
.home-button.btn-secondary:hover {
background: #22252d;
border-color: #3a3d45;
background: var(--btn-hover-bg, #22252d);
border-color: var(--border);
}
.home-button.btn-primary {
background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15));
border-color: #2a5580;
background: var(--blue-main);
color: white;
border-color: var(--blue-main);
}
.home-button.btn-primary:hover {
background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25));
border-color: #3a6590;
background: #0c5aa6;
border-color: #0c5aa6;
}
/* Card grid for added cards (responsive, compact tiles) */

View file

@ -39,16 +39,20 @@
/* Light blend between Slate and Parchment (leans gray) */
[data-theme="light-blend"]{
--bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
--panel: #ffffff; /* crisp panels for readability */
--text: #0b0d12;
--muted: #6b655d; /* slightly warm muted */
--border: #d6d1c7; /* neutral warm-gray border */
/* Slightly darker banner/sidebar for separation */
--surface-banner: #1a1b1e;
--surface-sidebar: #1a1b1e;
--surface-banner-text: #e8e8e8;
--surface-sidebar-text: #e8e8e8;
--bg: #e8e2d0; /* warm beige background (keep existing) */
--panel: #ebe5d8; /* lighter warm cream - more contrast with bg, subtle panels */
--text: #1a1410; /* dark brown for readability */
--muted: #6b655d; /* warm muted brown (keep existing) */
--border: #bfb5a3; /* darker warm-gray border for better definition */
/* Navbar/banner: darker warm brown for hierarchy */
--surface-banner: #9b8f7a; /* warm medium brown - darker than panels, lighter than dark theme */
--surface-sidebar: #9b8f7a; /* match banner for consistency */
--surface-banner-text: #1a1410; /* dark brown text on medium brown bg */
--surface-sidebar-text: #1a1410; /* dark brown text on medium brown bg */
/* Button colors: use taupe for buttons so they stand out from light panels */
--btn-bg: #d4cbb8; /* medium warm taupe - stands out against light panels */
--btn-text: #1a1410; /* dark brown text */
--btn-hover-bg: #c4b9a5; /* darker taupe on hover */
}
[data-theme="dark"]{
@ -282,20 +286,22 @@ small, .muted{ color: var(--muted); }
/* Home page darker buttons */
.home-button.btn-secondary {
background: #1a1d24;
border-color: #2a2d35;
background: var(--btn-bg, #1a1d24);
color: var(--btn-text, #e8e8e8);
border-color: var(--border);
}
.home-button.btn-secondary:hover {
background: #22252d;
border-color: #3a3d45;
background: var(--btn-hover-bg, #22252d);
border-color: var(--border);
}
.home-button.btn-primary {
background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15));
border-color: #2a5580;
background: var(--blue-main);
color: white;
border-color: var(--blue-main);
}
.home-button.btn-primary:hover {
background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25));
border-color: #3a6590;
background: #0c5aa6;
border-color: #0c5aa6;
}
/* Card grid for added cards (responsive, compact tiles) */

1393
code/web/static/ts/app.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,382 @@
/**
* M3 Component Library - TypeScript Utilities
*
* Core functions for interactive components:
* - Card flip button (dual-faced cards)
* - Collapsible panels
* - Card popups
* - Modal management
*
* Migrated from components.js with TypeScript types
*/
// ============================================
// TYPE DEFINITIONS
// ============================================
interface CardPopupOptions {
tags?: string[];
highlightTags?: string[];
role?: string;
layout?: string;
}
// ============================================
// CARD FLIP FUNCTIONALITY
// ============================================
/**
* Flip a dual-faced card image between front and back faces
* @param button - The flip button element
*/
function flipCard(button: HTMLElement): void {
const container = button.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null;
if (!container) return;
const img = container.querySelector('img') as HTMLImageElement | null;
if (!img) return;
const cardName = img.dataset.cardName;
if (!cardName) return;
const faces = cardName.split(' // ');
if (faces.length < 2) return;
// Determine current face (default to 0 = front)
const currentFace = parseInt(img.dataset.currentFace || '0', 10);
const nextFace = currentFace === 0 ? 1 : 0;
const faceName = faces[nextFace];
// Determine image version based on container
const isLarge = container.classList.contains('card-thumb-large') ||
container.classList.contains('card-popup-image');
const version = isLarge ? 'normal' : 'small';
// Update image source
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`;
img.alt = `${faceName} image`;
img.dataset.currentFace = nextFace.toString();
// Update button aria-label
const otherFace = faces[currentFace];
button.setAttribute('aria-label', `Flip to ${otherFace}`);
}
/**
* Reset all card images to show front face
* Useful when navigating between pages or clearing selections
*/
function resetCardFaces(): void {
document.querySelectorAll<HTMLImageElement>('img[data-card-name][data-current-face]').forEach(img => {
const cardName = img.dataset.cardName;
if (!cardName) return;
const faces = cardName.split(' // ');
if (faces.length > 1) {
const frontFace = faces[0];
const container = img.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null;
const isLarge = container && (container.classList.contains('card-thumb-large') ||
container.classList.contains('card-popup-image'));
const version = isLarge ? 'normal' : 'small';
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`;
img.alt = `${frontFace} image`;
img.dataset.currentFace = '0';
}
});
}
// ============================================
// COLLAPSIBLE PANEL FUNCTIONALITY
// ============================================
/**
* Toggle a collapsible panel's expanded/collapsed state
* @param panelId - The ID of the panel element
*/
function togglePanel(panelId: string): void {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
if (!button || !content) return;
const isExpanded = button.getAttribute('aria-expanded') === 'true';
// Toggle state
button.setAttribute('aria-expanded', (!isExpanded).toString());
content.style.display = isExpanded ? 'none' : 'block';
// Toggle classes
panel.classList.toggle('panel-expanded', !isExpanded);
panel.classList.toggle('panel-collapsed', isExpanded);
}
/**
* Expand a collapsible panel
* @param panelId - The ID of the panel element
*/
function expandPanel(panelId: string): void {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
if (!button || !content) return;
button.setAttribute('aria-expanded', 'true');
content.style.display = 'block';
panel.classList.add('panel-expanded');
panel.classList.remove('panel-collapsed');
}
/**
* Collapse a collapsible panel
* @param panelId - The ID of the panel element
*/
function collapsePanel(panelId: string): void {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
if (!button || !content) return;
button.setAttribute('aria-expanded', 'false');
content.style.display = 'none';
panel.classList.add('panel-collapsed');
panel.classList.remove('panel-expanded');
}
// ============================================
// MODAL MANAGEMENT
// ============================================
/**
* Open a modal by ID
* @param modalId - The ID of the modal element
*/
function openModal(modalId: string): void {
const modal = document.getElementById(modalId);
if (!modal) return;
(modal as HTMLElement).style.display = 'flex';
document.body.style.overflow = 'hidden';
// Focus first focusable element in modal
const focusable = modal.querySelector<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusable) {
setTimeout(() => focusable.focus(), 100);
}
}
/**
* Close a modal by ID or element
* @param modalOrId - Modal element or ID
*/
function closeModal(modalOrId: string | HTMLElement): void {
const modal = typeof modalOrId === 'string'
? document.getElementById(modalOrId)
: modalOrId;
if (!modal) return;
modal.remove();
// Restore body scroll if no other modals are open
if (!document.querySelector('.modal')) {
document.body.style.overflow = '';
}
}
/**
* Close all open modals
*/
function closeAllModals(): void {
document.querySelectorAll('.modal').forEach(modal => modal.remove());
document.body.style.overflow = '';
}
// ============================================
// CARD POPUP FUNCTIONALITY
// ============================================
/**
* Show card details popup on hover or tap
* @param cardName - The card name
* @param options - Popup options
*/
function showCardPopup(cardName: string, options: CardPopupOptions = {}): void {
// Remove any existing popup
closeCardPopup();
const {
tags = [],
highlightTags = [],
role = '',
layout = 'normal'
} = options;
const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout);
const baseName = cardName.split(' // ')[0];
// Create popup HTML
const popup = document.createElement('div');
popup.className = 'card-popup';
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-label', `${cardName} details`);
let tagsHTML = '';
if (tags.length > 0) {
tagsHTML = '<div class="card-popup-tags">';
tags.forEach(tag => {
const isHighlight = highlightTags.includes(tag);
tagsHTML += `<span class="card-popup-tag${isHighlight ? ' card-popup-tag-highlight' : ''}">${tag}</span>`;
});
tagsHTML += '</div>';
}
let roleHTML = '';
if (role) {
roleHTML = `<div class="card-popup-role">Role: <span>${role}</span></div>`;
}
let flipButtonHTML = '';
if (isDFC) {
flipButtonHTML = `
<button type="button" class="card-flip-btn" onclick="flipCard(this)" aria-label="Flip card">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 3.293l2.646 2.647.708-.708L8 2.879 4.646 5.232l.708.708L8 3.293zM8 12.707L5.354 10.06l-.708.708L8 13.121l3.354-2.353-.708-.708L8 12.707z"/>
</svg>
</button>
`;
}
popup.innerHTML = `
<div class="card-popup-backdrop" onclick="closeCardPopup()"></div>
<div class="card-popup-content">
<div class="card-popup-image">
<img src="https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(baseName)}&format=image&version=normal"
alt="${cardName} image"
data-card-name="${cardName}"
loading="lazy"
decoding="async" />
${flipButtonHTML}
</div>
<div class="card-popup-info">
<h3 class="card-popup-name">${cardName}</h3>
${roleHTML}
${tagsHTML}
</div>
<button type="button" class="card-popup-close" onclick="closeCardPopup()" aria-label="Close">×</button>
</div>
`;
document.body.appendChild(popup);
document.body.style.overflow = 'hidden';
// Focus close button
const closeBtn = popup.querySelector<HTMLElement>('.card-popup-close');
if (closeBtn) {
setTimeout(() => closeBtn.focus(), 100);
}
}
/**
* Close card popup
* @param element - Element to search from (optional)
*/
function closeCardPopup(element?: HTMLElement): void {
const popup = element
? element.closest('.card-popup')
: document.querySelector('.card-popup');
if (popup) {
popup.remove();
// Restore body scroll if no modals are open
if (!document.querySelector('.modal')) {
document.body.style.overflow = '';
}
}
}
/**
* Setup card thumbnail hover/tap events
* Call this after dynamically adding card thumbnails to the DOM
*/
function setupCardPopups(): void {
document.querySelectorAll<HTMLElement>('.card-thumb-container[data-card-name]').forEach(container => {
const img = container.querySelector<HTMLElement>('.card-thumb');
if (!img) return;
const cardName = container.dataset.cardName || img.dataset.cardName;
if (!cardName) return;
// Desktop: hover
container.addEventListener('mouseenter', function(e: MouseEvent) {
if (window.innerWidth > 768) {
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const role = img.dataset.role || '';
const layout = img.dataset.layout || 'normal';
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
}
});
// Mobile: tap
container.addEventListener('click', function(e: MouseEvent) {
if (window.innerWidth <= 768) {
e.preventDefault();
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const role = img.dataset.role || '';
const layout = img.dataset.layout || 'normal';
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
}
});
});
}
// ============================================
// INITIALIZATION
// ============================================
// Setup event listeners when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Setup card popups on initial load
setupCardPopups();
// Close modals/popups on Escape key
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeCardPopup();
// Close topmost modal only
const modals = document.querySelectorAll('.modal');
if (modals.length > 0) {
closeModal(modals[modals.length - 1] as HTMLElement);
}
}
});
});
} else {
// DOM already loaded
setupCardPopups();
}
// Make functions globally available for inline onclick handlers
(window as any).flipCard = flipCard;
(window as any).resetCardFaces = resetCardFaces;
(window as any).togglePanel = togglePanel;
(window as any).expandPanel = expandPanel;
(window as any).collapsePanel = collapsePanel;
(window as any).openModal = openModal;
(window as any).closeModal = closeModal;
(window as any).closeAllModals = closeAllModals;
(window as any).showCardPopup = showCardPopup;
(window as any).closeCardPopup = closeCardPopup;
(window as any).setupCardPopups = setupCardPopups;

105
code/web/static/ts/types.ts Normal file
View file

@ -0,0 +1,105 @@
/* Shared TypeScript type definitions for MTG Deckbuilder web app */
// Toast system types
export interface ToastOptions {
duration?: number;
}
// State management types
export interface StateManager {
get(key: string, def?: any): any;
set(key: string, val: any): void;
inHash(obj: Record<string, any>): void;
readHash(): URLSearchParams;
}
// Telemetry types
export interface TelemetryManager {
send(eventName: string, data?: Record<string, any>): void;
}
// Skeleton system types
export interface SkeletonManager {
show(context?: HTMLElement | Document): void;
hide(context?: HTMLElement | Document): void;
}
// Card popup types (from components.ts)
export interface CardPopupOptions {
tags?: string[];
highlightTags?: string[];
role?: string;
layout?: string;
showActions?: boolean;
}
// HTMX event detail types
export interface HtmxResponseErrorDetail {
xhr?: XMLHttpRequest;
path?: string;
target?: HTMLElement;
}
export interface HtmxEventDetail {
target?: HTMLElement;
elt?: HTMLElement;
path?: string;
xhr?: XMLHttpRequest;
}
// HTMX cache interface
export interface HtmxCache {
get(key: string): any;
set(key: string, html: string, ttl?: number, meta?: any): void;
apply(elt: any, detail: any, entry: any): void;
buildKey(detail: any, elt: any): string;
ttlFor(elt: any): number;
prefetch(url: string, opts?: any): void;
}
// Global window extensions
declare global {
interface Window {
__mtgState: StateManager;
toast: (msg: string | HTMLElement, type?: string, opts?: ToastOptions) => HTMLElement;
toastHTML: (html: string, type?: string, opts?: ToastOptions) => HTMLElement;
appTelemetry: TelemetryManager;
skeletons: SkeletonManager;
__telemetryEndpoint?: string;
showCardPopup?: (cardName: string, options?: CardPopupOptions) => void;
dismissCardPopup?: () => void;
flipCard?: (button: HTMLElement) => void;
htmxCache?: HtmxCache;
htmx?: any; // HTMX library - use any for external library
initHtmxDebounce?: () => void;
scrollCardIntoView?: (card: HTMLElement) => void;
__virtGlobal?: any;
__virtHotkeyBound?: boolean;
}
interface CustomEvent<T = any> {
readonly detail: T;
}
// HTMX custom events
interface DocumentEventMap {
'htmx:responseError': CustomEvent<HtmxResponseErrorDetail>;
'htmx:sendError': CustomEvent<any>;
'htmx:afterSwap': CustomEvent<HtmxEventDetail>;
'htmx:beforeRequest': CustomEvent<HtmxEventDetail>;
'htmx:afterSettle': CustomEvent<HtmxEventDetail>;
'htmx:afterRequest': CustomEvent<HtmxEventDetail>;
}
interface HTMLElement {
__hxCacheKey?: string;
__hxCacheTTL?: number;
}
interface Element {
__hxPrefetched?: boolean;
}
}
// Empty export to make this a module file
export {};

View file

@ -628,8 +628,8 @@
} catch(_) {}
})();
</script>
<script src="/static/components.js?v=20250121-1"></script>
<script src="/static/app.js?v=20250826-4"></script>
<script src="/static/js/components.js?v=20251028-1"></script>
<script src="/static/js/app.js?v=20250826-4"></script>
{% if enable_themes %}
<script>
(function(){

View file

@ -3,9 +3,9 @@
{% for cand in candidates %}
<li>
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
hx-get="/build/new/inspect?name={{ cand.display|urlencode }}"
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
hx-on="htmx:afterOnLoad: (function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} try{ ci.dispatchEvent(new Event('input', { bubbles: true })); }catch(_){ } } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)">
onclick="(function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)"
hx-get="/build/new/inspect?name={{ cand.value|urlencode }}"
hx-target="#newdeck-tags-slot" hx-swap="innerHTML">
{{ cand.display }}
{% if cand.warning %}
<span aria-hidden="true" style="margin-left:.35rem; font-size:11px; color:#facc15;"></span>

View file

@ -20,9 +20,7 @@
<span>Commander</span>
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
hx-get="/build/new/candidates" hx-trigger="debouncedinput change" hx-target="#newdeck-candidates" hx-sync="this:replace"
data-hx-debounce="220" data-hx-debounce-events="input"
data-hx-debounce-flush="blur" />
hx-get="/build/new/candidates" hx-trigger="input changed delay:220ms, change" hx-target="#newdeck-candidates" hx-sync="this:replace" />
</label>
<small class="muted block mt-1">Start typing to see matches, then select one to load themes.</small>
<div id="newdeck-candidates" class="muted text-xs min-h-[1.1em]"></div>

View file

@ -32,16 +32,26 @@
if(!(target.tagName && target.tagName.toLowerCase() === 'body')) return;
var init = document.getElementById('builder-init');
var preset = init && init.dataset ? (init.dataset.commander || '') : '';
if(!preset) return;
console.log('[Quick-build] DEBUG: preset=', preset);
if(!preset) {
console.log('[Quick-build] DEBUG: No preset, exiting');
return;
}
var input = document.querySelector('input[name="commander"]');
console.log('[Quick-build] DEBUG: Found input?', !!input);
if(input){
if(!input.value){ input.value = preset; }
console.log('[Quick-build] DEBUG: Input current value:', input.value);
if(!input.value){
input.value = preset;
console.log('[Quick-build] DEBUG: Set input to:', preset);
}
try { input.dispatchEvent(new Event('input', {bubbles:true})); } catch(_){ }
try { input.focus(); } catch(_){ }
}
// If htmx is available, auto-load the inspect view for an exact preset name.
try {
if (window.htmx && preset && typeof window.htmx.ajax === 'function'){
console.log('[Quick-build] DEBUG: Calling inspect with:', preset);
window.htmx.ajax('GET', '/build/new/inspect?name=' + encodeURIComponent(preset), { target: '#newdeck-tags-slot', swap: 'innerHTML' });
// Also try to load multi-copy suggestions based on current radio defaults
setTimeout(function(){

View file

@ -5,34 +5,22 @@
{% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
<div class="commander-thumb">
{% set small = record.image_small_url or record.image_normal_url %}
{% set normal = record.image_normal_url or small %}
{% set small = record.display_name|card_image('small') %}
{% set normal = record.display_name|card_image('normal') %}
<img
src="{{ placeholder_pixel }}"
src="{{ small }}"
srcset="{{ small }} 160w, {{ normal }} 488w"
sizes="(max-width: 900px) 60vw, 160px"
alt="{{ record.display_name }} card art"
loading="lazy"
decoding="async"
width="160"
height="223"
data-lazy-image="commander"
data-lazy-src="{{ small }}"
data-lazy-srcset="{{ small }} 160w, {{ normal }} 488w"
data-lazy-sizes="(max-width: 900px) 60vw, 160px"
data-card-name="{{ record.display_name }}"
data-original-name="{{ record.name }}"
data-hover-simple="true"
class="commander-thumb-img"
/>
<noscript>
<img
src="{{ small }}"
srcset="{{ small }} 160w, {{ normal }} 488w"
sizes="160px"
alt="{{ record.display_name }} card art"
width="160"
height="223"
/>
</noscript>
</div>
<div class="commander-main">
<div class="commander-header">