overhaul: migrated to tailwind css for css management, consolidated custom css, removed inline css, removed unneeded css, and otherwise improved page styling

This commit is contained in:
matt 2025-10-28 08:21:52 -07:00
parent f1e21873e7
commit b994978f60
81 changed files with 15784 additions and 2936 deletions

View file

@ -0,0 +1,225 @@
{# Button Component Library #}
{# Usage: {{ import_buttons() }} then call button macros #}
{#
Primary Button Macro
Parameters:
- text (str): Button text/label
- variant (str): 'primary', 'secondary', 'ghost', 'danger' (default: 'primary')
- type (str): 'button', 'submit', 'reset' (default: 'button')
- size (str): 'sm', 'md', 'lg' (default: 'md')
- href (str): If provided, renders as <a> tag instead of <button>
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes (e.g., 'disabled', 'data-foo="bar"')
- hx_get (str): HTMX hx-get attribute
- hx_post (str): HTMX hx-post attribute
- hx_target (str): HTMX hx-target attribute
- hx_swap (str): HTMX hx-swap attribute
- onclick (str): JavaScript onclick handler
- aria_label (str): ARIA label for accessibility
Examples:
{{ button('Click Me') }}
{{ button('Submit', variant='primary', type='submit') }}
{{ button('Cancel', variant='secondary') }}
{{ button('Delete', variant='danger', onclick='confirmDelete()') }}
{{ button('Go Back', variant='ghost', href='/build') }}
{{ button('Load More', hx_get='/cards?page=2', hx_target='#cards') }}
#}
{% macro button(text, variant='primary', type='button', size='md', href='', classes='', attrs='', hx_get='', hx_post='', hx_target='', hx_swap='', onclick='', aria_label='') %}
{%- set base_classes = 'btn' -%}
{%- set variant_class = 'btn-' + variant if variant != 'primary' else '' -%}
{%- set size_class = 'btn-' + size if size != 'md' else '' -%}
{%- set all_classes = [base_classes, variant_class, size_class, classes]|select|join(' ') -%}
{%- if href -%}
<a href="{{ href }}"
class="{{ all_classes }}"
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{{ attrs|safe }}>{{ text }}</a>
{%- else -%}
<button type="{{ type }}"
class="{{ all_classes }}"
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{{ attrs|safe }}>{{ text }}</button>
{%- endif -%}
{% endmacro %}
{#
Icon Button Macro
Parameters:
- icon (str): Icon character or HTML (e.g., '×', '☰', '<svg>...</svg>')
- variant (str): 'primary', 'secondary', 'ghost', 'danger' (default: 'ghost')
- size (str): 'sm', 'md', 'lg' (default: 'md')
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
- onclick (str): JavaScript onclick handler
- aria_label (str): Required ARIA label for accessibility
Examples:
{{ icon_button('×', aria_label='Close', onclick='closeModal()') }}
{{ icon_button('☰', aria_label='Menu', variant='primary') }}
#}
{% macro icon_button(icon, variant='ghost', size='md', classes='', attrs='', onclick='', aria_label='') %}
{%- set base_classes = 'btn btn-icon' -%}
{%- set variant_class = 'btn-' + variant if variant != 'ghost' else '' -%}
{%- set size_class = 'btn-' + size if size != 'md' else '' -%}
{%- set all_classes = [base_classes, variant_class, size_class, classes]|select|join(' ') -%}
<button type="button"
class="{{ all_classes }}"
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% else %}aria-label="Icon button"{% endif %}
{{ attrs|safe }}>{{ icon|safe }}</button>
{% endmacro %}
{#
Close Button Macro (specialized icon button)
Parameters:
- target (str): CSS selector of element to close (default: closest '.modal')
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
- aria_label (str): ARIA label (default: 'Close')
Examples:
{{ close_button() }}
{{ close_button(target='.alts') }}
{{ close_button(classes='modal-close') }}
#}
{% macro close_button(target='.modal', classes='', attrs='', aria_label='Close') %}
{{ icon_button(
'×',
variant='ghost',
size='sm',
classes='btn-close ' + classes,
onclick="try{this.closest('" + target + "').remove();}catch(_){}",
aria_label=aria_label,
attrs=attrs
) }}
{% endmacro %}
{#
Button Group Macro
Parameters:
- buttons (list): List of button dicts with keys: text, variant, type, href, onclick, etc.
- alignment (str): 'left', 'center', 'right', 'between' (default: 'right')
- classes (str): Additional CSS classes for container
Examples:
{{ button_group([
{'text': 'Cancel', 'variant': 'secondary', 'onclick': 'close()'},
{'text': 'Save', 'variant': 'primary', 'type': 'submit'}
]) }}
#}
{% macro button_group(buttons, alignment='right', classes='') %}
{%- set alignment_class = 'btn-group-' + alignment -%}
<div class="btn-group {{ alignment_class }} {{ classes }}">
{% for btn in buttons %}
{{ button(
btn.text,
variant=btn.get('variant', 'primary'),
type=btn.get('type', 'button'),
size=btn.get('size', 'md'),
href=btn.get('href', ''),
classes=btn.get('classes', ''),
attrs=btn.get('attrs', ''),
hx_get=btn.get('hx_get', ''),
hx_post=btn.get('hx_post', ''),
hx_target=btn.get('hx_target', ''),
hx_swap=btn.get('hx_swap', ''),
onclick=btn.get('onclick', ''),
aria_label=btn.get('aria_label', '')
) }}
{% endfor %}
</div>
{% endmacro %}
{#
Tag/Chip Button Macro
Parameters:
- text (str): Tag text
- removable (bool): Show remove 'x' button (default: False)
- selected (bool): Tag is selected (default: False)
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
- onclick (str): JavaScript onclick handler
- on_remove (str): JavaScript handler for remove button
- data_attrs (dict): Data attributes as key-value pairs
Examples:
{{ tag_button('Flying') }}
{{ tag_button('Ramp', selected=True) }}
{{ tag_button('Blue', removable=True, on_remove='removeTag(this)') }}
{{ tag_button('Simic', data_attrs={'color': 'ug', 'value': '2'}) }}
#}
{% macro tag_button(text, removable=False, selected=False, classes='', attrs='', onclick='', on_remove='', data_attrs={}) %}
{%- set base_classes = 'btn btn-tag' -%}
{%- set state_class = 'btn-tag-selected' if selected else '' -%}
{%- set all_classes = [base_classes, state_class, classes]|select|join(' ') -%}
<button type="button"
class="{{ all_classes }}"
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% for key, value in data_attrs.items() %}data-{{ key }}="{{ value }}" {% endfor %}
{{ attrs|safe }}>
<span>{{ text }}</span>
{% if removable %}
<button type="button"
class="btn-tag-remove"
aria-label="Remove {{ text }}"
{% if on_remove %}onclick="{{ on_remove }}"{% else %}onclick="this.closest('.btn-tag').remove()"{% endif %}>×</button>
{% endif %}
</button>
{% endmacro %}
{#
Action Button (Legacy - use button() with variant instead)
Kept for backward compatibility during migration
Parameters: Same as button()
Note: This is deprecated. Use {{ button(text, variant='primary', size='lg') }} instead.
#}
{% macro action_button(text, type='button', classes='', attrs='', onclick='', aria_label='') %}
{{ button(text, variant='primary', type=type, size='lg', classes='action-btn ' + classes, attrs=attrs, onclick=onclick, aria_label=aria_label) }}
{% endmacro %}
{# CSS Classes Reference #}
{#
Button Variants:
- .btn (base)
- .btn-primary (default)
- .btn-secondary (gray, for cancel/back)
- .btn-ghost (transparent, subtle)
- .btn-danger (red, for destructive actions)
Button Sizes:
- .btn-sm (small: padding 4px 12px, font 12px)
- .btn-md (default: padding 8px 16px, font 14px)
- .btn-lg (large: padding 12px 24px, font 16px)
Button Modifiers:
- .btn-icon (icon-only button, square aspect)
- .btn-close (close button, positioned top-right)
- .btn-tag (pill-shaped tag/chip)
- .btn-tag-selected (selected tag state)
- .btn-tag-remove (remove button within tag)
Button Groups:
- .btn-group (container)
- .btn-group-left (align left)
- .btn-group-center (align center)
- .btn-group-right (align right, default)
- .btn-group-between (space-between)
#}

View file

@ -0,0 +1,375 @@
{# Card Display Component Library #}
{# Usage: {{ import '_card_display.html' }} then call card macros #}
{#
Card Thumbnail Macro
Parameters:
- name (str): Card name (required)
- size (str): 'small' (160px), 'medium' (230px), 'large' (360px) (default: 'medium')
- layout (str): Card layout type ('modal_dfc', 'transform', 'normal', etc.)
- version (str): Scryfall image version ('small', 'normal', 'large') (auto-selected by size)
- loading (str): 'lazy', 'eager' (default: 'lazy')
- show_flip (bool): Show flip button for dual-faced cards (default: True)
- show_name (bool): Show card name label below image (default: False)
- classes (str): Additional CSS classes for container
- img_classes (str): Additional CSS classes for img tag
- data_attrs (dict): Additional data attributes as key-value pairs
- role (str): Card role (commander, ramp, removal, etc.)
- tags (list or str): Theme/mechanic tags (list or comma-separated string)
- overlaps (list or str): Theme overlaps
- count (int): Card count in deck
- lqip (bool): Use low-quality image placeholder (default: True)
- onclick (str): JavaScript onclick handler
Examples:
{{ card_thumb('Sol Ring', size='medium') }}
{{ card_thumb('Halana, Kessig Ranger', size='large', show_name=True) }}
{{ card_thumb('Delver of Secrets', layout='transform', show_flip=True) }}
{{ card_thumb('Rampant Growth', role='ramp', tags=['Ramp', 'Green']) }}
#}
{% macro card_thumb(name, size='medium', layout='normal', version='', loading='lazy', show_flip=True, show_name=False, classes='', img_classes='', data_attrs={}, role='', tags='', overlaps='', count=0, lqip=True, onclick='') %}
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
{%- set is_dfc = layout in ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'] -%}
{# Auto-select Scryfall image version based on size #}
{%- if not version -%}
{%- if size == 'small' -%}
{%- set version = 'small' -%}
{%- elif size == 'large' -%}
{%- set version = 'normal' -%}
{%- else -%}
{%- set version = 'small' -%}
{%- endif -%}
{%- endif -%}
{# Build CSS classes #}
{%- set size_class = 'card-thumb-' + size -%}
{%- set dfc_class = 'card-thumb-dfc' if is_dfc else '' -%}
{%- set container_classes = ['card-thumb-container', size_class, dfc_class, classes]|select|join(' ') -%}
{%- set img_base_classes = 'card-thumb' -%}
{%- set all_img_classes = [img_base_classes, img_classes]|select|join(' ') -%}
{# Build data attributes #}
{%- set all_data_attrs = {
'card-name': base_name,
'layout': layout
} -%}
{%- if role -%}
{%- set _ = all_data_attrs.update({'role': role}) -%}
{%- endif -%}
{%- if tags -%}
{%- set tags_str = tags if tags is string else tags|join(', ') -%}
{%- set _ = all_data_attrs.update({'tags': tags_str}) -%}
{%- endif -%}
{%- if overlaps -%}
{%- set overlaps_str = overlaps if overlaps is string else overlaps|join(',') -%}
{%- set _ = all_data_attrs.update({'overlaps': overlaps_str}) -%}
{%- endif -%}
{%- if count > 0 -%}
{%- set _ = all_data_attrs.update({'count': count|string}) -%}
{%- endif -%}
{%- if lqip -%}
{%- set _ = all_data_attrs.update({'lqip': '1'}) -%}
{%- endif -%}
{%- set _ = all_data_attrs.update(data_attrs) -%}
<div class="{{ container_classes }}" {% if onclick %}onclick="{{ onclick }}"{% endif %}>
<img class="{{ all_img_classes }}"
loading="{{ loading }}"
decoding="async"
src="{{ base_name|card_image(version) }}"
alt="{{ name }} image"
{% for key, value in all_data_attrs.items() %}data-{{ key }}="{{ value }}" {% endfor %}
{% if lqip %}style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';"{% endif %} />
{% if is_dfc and show_flip %}
{{ card_flip_button(name) }}
{% endif %}
{% if show_name %}
<div class="card-name-label" data-card-name="{{ base_name }}">{{ name }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Card Flip Button Macro
Parameters:
- name (str): Full card name (with // separator for DFCs)
- classes (str): Additional CSS classes
- aria_label (str): ARIA label (default: auto-generated)
Examples:
{{ card_flip_button('Delver of Secrets // Insectile Aberration') }}
#}
{% macro card_flip_button(name, classes='', aria_label='') %}
{%- set faces = name.split(' // ') -%}
{%- set label = aria_label if aria_label else 'Flip to ' + (faces[1] if faces|length > 1 else 'other face') -%}
<button type="button"
class="card-flip-btn {{ classes }}"
data-card-name="{{ name }}"
onclick="flipCard(this)"
aria-label="{{ label }}">
<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>
{% endmacro %}
{#
Card Hover Popup Macro
Parameters:
- name (str): Card name (required)
- layout (str): Card layout type
- tags (list or str): Theme/mechanic tags
- highlight_tags (list or str): Tags to highlight
- role (str): Card role
- show_flip (bool): Show flip button for DFCs (default: True)
- classes (str): Additional CSS classes
Note: This macro generates the popup HTML. Actual hover/tap behavior
should be handled by JavaScript (see card_popup.js)
Examples:
{{ card_popup('Sol Ring', tags=['Ramp', 'Artifact']) }}
{{ card_popup('Delver of Secrets', layout='transform', show_flip=True) }}
#}
{% macro card_popup(name, layout='normal', tags='', highlight_tags='', role='', show_flip=True, classes='') %}
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
{%- set is_dfc = layout in ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'] -%}
{%- set tags_list = tags if tags is sequence and tags is not string else (tags.split(', ') if tags else []) -%}
{%- set highlight_list = highlight_tags if highlight_tags is sequence and highlight_tags is not string else (highlight_tags.split(', ') if highlight_tags else []) -%}
<div class="card-popup {{ classes }}" data-card-name="{{ base_name }}" role="dialog" aria-label="{{ name }} details">
<div class="card-popup-backdrop" onclick="closeCardPopup(this)"></div>
<div class="card-popup-content">
{# Card Image (360px) #}
<div class="card-popup-image">
<img src="{{ base_name|card_image('normal') }}"
alt="{{ name }} image"
data-card-name="{{ base_name }}"
loading="lazy"
decoding="async" />
{% if is_dfc and show_flip %}
{{ card_flip_button(name) }}
{% endif %}
</div>
{# Card Info #}
<div class="card-popup-info">
<h3 class="card-popup-name">{{ name }}</h3>
{% if role %}
<div class="card-popup-role">Role: <span>{{ role }}</span></div>
{% endif %}
{% if tags_list %}
<div class="card-popup-tags">
{% for tag in tags_list %}
{%- set is_highlight = tag in highlight_list -%}
<span class="card-popup-tag{% if is_highlight %} card-popup-tag-highlight{% endif %}">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{# Close Button #}
<button type="button"
class="card-popup-close"
onclick="closeCardPopup(this)"
aria-label="Close">×</button>
</div>
</div>
{% endmacro %}
{#
Card Grid Container Macro
Parameters:
- cards (list): List of card dicts with keys: name, layout, role, tags, count, etc.
- size (str): Thumbnail size ('small', 'medium', 'large')
- columns (int or str): Number of columns (auto, 2, 3, 4, 5, 6) (default: 'auto')
- gap (str): Grid gap (default: '0.75rem')
- show_names (bool): Show card name labels (default: False)
- show_popups (bool): Enable hover/tap popups (default: True)
- classes (str): Additional CSS classes
Examples:
{{ card_grid(deck_cards, size='medium', columns=4) }}
{{ card_grid(commander_examples, size='large', show_names=True) }}
#}
{% macro card_grid(cards, size='medium', columns='auto', gap='0.75rem', show_names=False, show_popups=True, classes='') %}
{%- set columns_class = 'card-grid-cols-' + (columns|string) -%}
{%- set popup_class = 'card-grid-with-popups' if show_popups else '' -%}
{%- set all_classes = ['card-grid', columns_class, popup_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" style="gap: {{ gap }};">
{% for card in cards %}
{{ card_thumb(
name=card.name,
size=size,
layout=card.get('layout', 'normal'),
role=card.get('role', ''),
tags=card.get('tags', []),
overlaps=card.get('overlaps', []),
count=card.get('count', 0),
show_name=show_names,
show_flip=True
) }}
{% endfor %}
</div>
{% endmacro %}
{#
Card List Item Macro (for vertical lists)
Parameters:
- name (str): Card name (required)
- count (int): Card quantity (default: 1)
- role (str): Card role
- tags (list or str): Theme/mechanic tags
- show_thumb (bool): Show thumbnail image (default: True)
- thumb_size (str): Thumbnail size if shown (default: 'small')
- classes (str): Additional CSS classes
Examples:
{{ card_list_item('Sol Ring', count=1, role='ramp') }}
{{ card_list_item('Rampant Growth', count=1, tags=['Ramp', 'Green'], show_thumb=True) }}
#}
{% macro card_list_item(name, count=1, role='', tags='', show_thumb=True, thumb_size='small', classes='') %}
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
{%- set tags_str = tags if tags is string else (tags|join(', ') if tags else '') -%}
<li class="card-list-item {{ classes }}" data-card-name="{{ base_name }}">
{% if show_thumb %}
{{ card_thumb(name, size=thumb_size, show_flip=False, role=role, tags=tags) }}
{% endif %}
<div class="card-list-item-info">
<span class="card-list-item-name">{{ name }}</span>
{% if count > 1 %}
<span class="card-list-item-count">×{{ count }}</span>
{% endif %}
{% if role %}
<span class="card-list-item-role">{{ role }}</span>
{% endif %}
</div>
</li>
{% endmacro %}
{#
Synthetic Card Placeholder Macro (for theme previews)
Parameters:
- name (str): Card name (required)
- tags (list or str): Theme/mechanic tags
- reasons (list or str): Inclusion reasons
- classes (str): Additional CSS classes
Examples:
{{ synthetic_card('Placeholder Ramp', tags=['Ramp'], reasons=['synergy with commander']) }}
#}
{% macro synthetic_card(name, tags='', reasons='', classes='') %}
{%- set tags_str = tags if tags is string else (tags|join(', ') if tags else '') -%}
{%- set reasons_str = reasons if reasons is string else (reasons|join('; ') if reasons else '') -%}
<div class="card-sample synthetic {{ classes }}"
data-card-name="{{ name }}"
data-role="synthetic"
data-tags="{{ tags_str }}"
data-reasons="{{ reasons_str }}">
<div class="synthetic-card-placeholder">
<div class="synthetic-card-icon">?</div>
<div class="synthetic-card-name">{{ name }}</div>
{% if reasons_str %}
<div class="synthetic-card-reason">{{ reasons_str }}</div>
{% endif %}
</div>
</div>
{% endmacro %}
{# CSS Classes Reference #}
{#
Card Thumbnail Sizes:
- .card-thumb-small (160px width, for lists and grids)
- .card-thumb-medium (230px width, for previews and examples, default)
- .card-thumb-large (360px width, for prominent displays and deck views)
Card Thumbnail Modifiers:
- .card-thumb-dfc (dual-faced card, shows flip button)
- .card-thumb-container (wrapper with position relative)
- .card-thumb (img tag with consistent styling)
Card Flip Button:
- .card-flip-btn (flip button overlay on card image)
Card Popup:
- .card-popup (popup container, fixed positioning)
- .card-popup-backdrop (backdrop overlay)
- .card-popup-content (popup content box)
- .card-popup-image (360px card image)
- .card-popup-info (card name, role, tags)
- .card-popup-name (card name heading)
- .card-popup-role (role label)
- .card-popup-tags (tag list)
- .card-popup-tag (individual tag)
- .card-popup-tag-highlight (highlighted tag)
- .card-popup-close (close button)
Card Grid:
- .card-grid (grid container)
- .card-grid-cols-auto (auto columns based on card size)
- .card-grid-cols-2, .card-grid-cols-3, etc. (fixed columns)
- .card-grid-with-popups (enables popup on hover/tap)
Card List:
- .card-list-item (list item with thumbnail and info)
- .card-list-item-info (text info container)
- .card-list-item-name (card name)
- .card-list-item-count (quantity indicator)
- .card-list-item-role (role label)
Synthetic Cards:
- .card-sample.synthetic (synthetic card placeholder)
- .synthetic-card-placeholder (placeholder content)
- .synthetic-card-icon (question mark icon)
- .synthetic-card-name (placeholder name)
- .synthetic-card-reason (inclusion reason text)
#}
{# JavaScript Helper Functions #}
{#
These functions should be included in card_display.js or inline script:
// Flip dual-faced card image
function flipCard(button) {
const container = button.closest('.card-thumb-container, .card-popup-image');
const img = container.querySelector('img');
const cardName = img.dataset.cardName;
const faces = cardName.split(' // ');
if (faces.length < 2) return;
// Toggle current face
const currentFace = img.dataset.currentFace || 0;
const nextFace = currentFace == 0 ? 1 : 0;
const faceName = faces[nextFace];
// Update image source
img.src = '/api/images/normal/' + encodeURIComponent(faceName);
img.dataset.currentFace = nextFace;
}
// Show card popup on hover/tap
function showCardPopup(cardName, event) {
// Implementation depends on popup positioning strategy
// Could append popup to body and position near cursor/tap location
}
// Close card popup
function closeCardPopup(element) {
const popup = element.closest('.card-popup');
if (popup) popup.remove();
}
#}

View file

@ -0,0 +1,396 @@
{# Form Component Library #}
{# Usage: {{ import '_forms.html' }} then call form macros #}
{#
Form Field Wrapper Macro
Parameters:
- label (str): Field label text
- name (str): Input name attribute
- required (bool): Mark field as required (default: False)
- help_text (str): Optional help text below field
- error (str): Error message to display
- classes (str): Additional CSS classes for wrapper
Content Block:
- body: Input element (required)
Examples:
{% call form_field('Email', 'email', required=True) %}
{% block body %}
<input type="email" name="email" />
{% endblock %}
{% endcall %}
#}
{% macro form_field(label='', name='', required=False, help_text='', error='', classes='') %}
{%- set has_error = error|length > 0 -%}
{%- set error_class = 'form-field-error' if has_error else '' -%}
{%- set all_classes = ['form-field', error_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if label %}
<label for="{{ name }}" class="form-label">
{{ label }}
{% if required %}<span class="form-required" aria-label="required">*</span>{% endif %}
</label>
{% endif %}
<div class="form-input-wrapper">
{{ caller.body() }}
</div>
{% if help_text %}
<div class="form-help-text">{{ help_text }}</div>
{% endif %}
{% if error %}
<div class="form-error-text" role="alert">{{ error }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Text Input Macro
Parameters:
- name (str): Input name attribute (required)
- label (str): Field label
- type (str): Input type ('text', 'email', 'password', 'url', etc.) (default: 'text')
- value (str): Input value
- placeholder (str): Placeholder text
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- readonly (bool): Read-only field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ text_input('username', label='Username', required=True) }}
{{ text_input('email', label='Email', type='email', placeholder='you@example.com') }}
#}
{% macro text_input(name, label='', type='text', value='', placeholder='', required=False, disabled=False, readonly=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<input type="{{ type }}"
id="{{ name }}"
name="{{ name }}"
class="form-input"
{% if value %}value="{{ value }}"{% endif %}
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{{ attrs|safe }} />
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Textarea Macro
Parameters:
- name (str): Textarea name attribute (required)
- label (str): Field label
- value (str): Textarea value
- placeholder (str): Placeholder text
- rows (int): Number of visible rows (default: 4)
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- readonly (bool): Read-only field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ textarea('notes', label='Notes', rows=6) }}
{{ textarea('description', label='Description', placeholder='Enter description...') }}
#}
{% macro textarea(name, label='', value='', placeholder='', rows=4, required=False, disabled=False, readonly=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<textarea id="{{ name }}"
name="{{ name }}"
class="form-textarea"
rows="{{ rows }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{{ attrs|safe }}>{{ value }}</textarea>
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Select Dropdown Macro
Parameters:
- name (str): Select name attribute (required)
- label (str): Field label
- options (list): List of option dicts with keys: value, text, selected, disabled
- value (str): Selected value
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ select('color', label='Color', options=[
{'value': 'W', 'text': 'White'},
{'value': 'U', 'text': 'Blue'},
{'value': 'B', 'text': 'Black'}
]) }}
#}
{% macro select(name, label='', options=[], value='', required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<select id="{{ name }}"
name="{{ name }}"
class="form-select"
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }}>
{% for option in options %}
<option value="{{ option.value }}"
{% if option.value == value or option.get('selected') %}selected{% endif %}
{% if option.get('disabled') %}disabled{% endif %}>
{{ option.text }}
</option>
{% endfor %}
</select>
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Checkbox Macro
Parameters:
- name (str): Checkbox name attribute (required)
- label (str): Checkbox label text (required)
- value (str): Checkbox value (default: '1')
- checked (bool): Checked state (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ checkbox('accept_terms', label='I accept the terms', required=True) }}
{{ checkbox('owned_only', label='Owned cards only', checked=True) }}
#}
{% macro checkbox(name, label, value='1', checked=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{%- set has_error = error|length > 0 -%}
{%- set error_class = 'form-field-error' if has_error else '' -%}
{%- set all_classes = ['form-field', 'form-field-checkbox', error_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
<label class="form-checkbox-label">
<input type="checkbox"
id="{{ name }}"
name="{{ name }}"
class="form-checkbox"
value="{{ value }}"
{% if checked %}checked{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }} />
<span class="form-checkbox-text">{{ label }}</span>
</label>
{% if help_text %}
<div class="form-help-text">{{ help_text }}</div>
{% endif %}
{% if error %}
<div class="form-error-text" role="alert">{{ error }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Radio Button Group Macro
Parameters:
- name (str): Radio name attribute (required)
- label (str): Field label
- options (list): List of option dicts with keys: value, text, checked, disabled
- value (str): Selected value
- required (bool): Required field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
Examples:
{{ radio_group('theme', label='Theme', options=[
{'value': 'system', 'text': 'System'},
{'value': 'light', 'text': 'Light'},
{'value': 'dark', 'text': 'Dark'}
]) }}
#}
{% macro radio_group(name, label='', options=[], value='', required=False, help_text='', error='', classes='') %}
{%- set has_error = error|length > 0 -%}
{%- set error_class = 'form-field-error' if has_error else '' -%}
{%- set all_classes = ['form-field', 'form-field-radio', error_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if label %}
<div class="form-label">
{{ label }}
{% if required %}<span class="form-required" aria-label="required">*</span>{% endif %}
</div>
{% endif %}
<div class="form-radio-group">
{% for option in options %}
<label class="form-radio-label">
<input type="radio"
name="{{ name }}"
class="form-radio"
value="{{ option.value }}"
{% if option.value == value or option.get('checked') %}checked{% endif %}
{% if option.get('disabled') %}disabled{% endif %}
{% if required %}required{% endif %} />
<span class="form-radio-text">{{ option.text }}</span>
</label>
{% endfor %}
</div>
{% if help_text %}
<div class="form-help-text">{{ help_text }}</div>
{% endif %}
{% if error %}
<div class="form-error-text" role="alert">{{ error }}</div>
{% endif %}
</div>
{% endmacro %}
{#
Number Input Macro
Parameters:
- name (str): Input name attribute (required)
- label (str): Field label
- value (int or float): Input value
- min (int or float): Minimum value
- max (int or float): Maximum value
- step (int or float): Step increment (default: 1)
- placeholder (str): Placeholder text
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ number_input('quantity', label='Quantity', min=1, max=10, value=1) }}
{{ number_input('price', label='Price', min=0, step=0.01, placeholder='0.00') }}
#}
{% macro number_input(name, label='', value='', min='', max='', step=1, placeholder='', required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<input type="number"
id="{{ name }}"
name="{{ name }}"
class="form-input form-input-number"
{% if value != '' %}value="{{ value }}"{% endif %}
{% if min != '' %}min="{{ min }}"{% endif %}
{% if max != '' %}max="{{ max }}"{% endif %}
step="{{ step }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }} />
{% endblock %}
{% endcall %}
{% endmacro %}
{#
File Input Macro
Parameters:
- name (str): Input name attribute (required)
- label (str): Field label
- accept (str): Accepted file types (e.g., '.csv,.txt', 'image/*')
- multiple (bool): Allow multiple files (default: False)
- required (bool): Required field (default: False)
- disabled (bool): Disabled field (default: False)
- help_text (str): Help text
- error (str): Error message
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Examples:
{{ file_input('deck_file', label='Upload Deck', accept='.csv,.txt') }}
{{ file_input('cards', label='Card Images', accept='image/*', multiple=True) }}
#}
{% macro file_input(name, label='', accept='', multiple=False, required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
{% call form_field(label, name, required, help_text, error, classes) %}
{% block body %}
<input type="file"
id="{{ name }}"
name="{{ name }}"
class="form-input form-input-file"
{% if accept %}accept="{{ accept }}"{% endif %}
{% if multiple %}multiple{% endif %}
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{{ attrs|safe }} />
{% endblock %}
{% endcall %}
{% endmacro %}
{#
Hidden Input Macro
Parameters:
- name (str): Input name attribute (required)
- value (str): Input value (required)
Examples:
{{ hidden_input('csrf_token', value='abc123') }}
#}
{% macro hidden_input(name, value) %}
<input type="hidden" name="{{ name }}" value="{{ value }}" />
{% endmacro %}
{# CSS Classes Reference #}
{#
Form Structure:
- .form-field (field wrapper)
- .form-field-error (error state)
- .form-field-checkbox (checkbox field modifier)
- .form-field-radio (radio field modifier)
- .form-label (label text)
- .form-required (required indicator *)
- .form-input-wrapper (input container)
- .form-help-text (help text below field)
- .form-error-text (error message)
Input Types:
- .form-input (base input class)
- .form-input-number (number input modifier)
- .form-input-file (file input modifier)
- .form-textarea (textarea)
- .form-select (select dropdown)
- .form-checkbox (checkbox input)
- .form-checkbox-label (checkbox label wrapper)
- .form-checkbox-text (checkbox label text)
- .form-radio (radio input)
- .form-radio-group (radio button container)
- .form-radio-label (radio label wrapper)
- .form-radio-text (radio label text)
Form Layout Utilities (to be defined in CSS):
- .form-row (horizontal row of fields)
- .form-cols-2, .form-cols-3 (multi-column layouts)
- .form-inline (inline form layout)
- .form-compact (reduced spacing)
#}

View file

@ -1,5 +1,19 @@
{# Reusable Jinja macros for UI elements #}
{#
Component Library Imports
To use components in your templates:
{% from 'partials/_macros.html' import component_name %}
Or import specific component libraries:
{% from 'partials/_buttons.html' import button, icon_button %}
{% from 'partials/_modals.html' import modal, simple_modal %}
{% from 'partials/_card_display.html' import card_thumb, card_grid %}
{% from 'partials/_forms.html' import text_input, select, checkbox %}
{% from 'partials/_panels.html' import panel, stat_panel %}
#}
{% macro lock_button(name, locked=False, from_list=False, target_selector='closest .lock-box') -%}
{# Emits a lock/unlock button with correct hx-vals and aria state. #}
<button type="button" class="btn-lock"

View file

@ -0,0 +1,351 @@
{# Modal Component Library #}
{# Usage: {{ import '_modals.html' }} then call modal macros #}
{#
Modal Container Macro
Parameters:
- id (str): Modal HTML id attribute (optional)
- title (str): Modal title (shows in header)
- size (str): 'sm' (480px), 'md' (620px), 'lg' (720px), 'xl' (960px) (default: 'md')
- position (str): 'center', 'top' (default: 'center')
- scrollable (bool): Allow content scrolling (default: True)
- classes (str): Additional CSS classes for modal container
- content_classes (str): Additional CSS classes for modal content box
- show_close (bool): Show close button in header (default: True)
- backdrop_click_close (bool): Close on backdrop click (default: True)
- role (str): ARIA role (default: 'dialog')
- aria_labelledby (str): ARIA labelledby id (default: auto-generated from title)
Content Blocks:
- header: Optional custom header content (overrides default title)
- body: Main modal content (required)
- footer: Optional footer with action buttons
Examples:
{% call modal(title='Edit Card', size='md') %}
{% block body %}
<form>...</form>
{% endblock %}
{% block footer %}
{{ button('Cancel', variant='secondary', onclick='closeModal()') }}
{{ button('Save', type='submit') }}
{% endblock %}
{% endcall %}
#}
{% macro modal(id='', title='', size='md', position='center', scrollable=True, classes='', content_classes='', show_close=True, backdrop_click_close=True, role='dialog', aria_labelledby='') %}
{%- set modal_id = id if id else 'modal-' ~ title|lower|replace(' ', '-') -%}
{%- set title_id = aria_labelledby if aria_labelledby else modal_id + '-title' -%}
{%- set size_class = 'modal-' + size -%}
{%- set position_class = 'modal-' + position -%}
{%- set scrollable_class = 'modal-scrollable' if scrollable else '' -%}
{%- set all_classes = ['modal', size_class, position_class, scrollable_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}"
{% if id %}id="{{ modal_id }}"{% endif %}
role="{{ role }}"
aria-modal="true"
aria-labelledby="{{ title_id }}">
{# Backdrop #}
<div class="modal-backdrop"
{% if backdrop_click_close %}onclick="try{this.closest('.modal').remove();}catch(_){}"{% endif %}></div>
{# Content Container #}
<div class="modal-content {{ content_classes }}">
{# Header #}
{% if caller.header is defined %}
{{ caller.header() }}
{% else %}
{% if title or show_close %}
<div class="modal-header">
{% if title %}
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
{% endif %}
{% if show_close %}
{% from '_buttons.html' import close_button %}
{{ close_button() }}
{% endif %}
</div>
{% endif %}
{% endif %}
{# Body #}
<div class="modal-body">
{{ caller.body() }}
</div>
{# Footer #}
{% if caller.footer is defined %}
<div class="modal-footer">
{{ caller.footer() }}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{#
Simple Modal Macro (no block structure)
Parameters: Same as modal() plus:
- content (str): Body HTML content
- footer_buttons (list): List of button dicts (see button_group in _buttons.html)
Examples:
{{ simple_modal(
title='Confirm Delete',
content='<p>Are you sure you want to delete this deck?</p>',
footer_buttons=[
{'text': 'Cancel', 'variant': 'secondary', 'onclick': 'closeModal()'},
{'text': 'Delete', 'variant': 'danger', 'onclick': 'deleteDeck()'}
]
) }}
#}
{% macro simple_modal(title='', content='', footer_buttons=[], id='', size='md', position='center', scrollable=True, classes='', show_close=True) %}
{%- set modal_id = id if id else 'modal-' ~ title|lower|replace(' ', '-') -%}
{%- set title_id = modal_id + '-title' -%}
{%- set size_class = 'modal-' + size -%}
{%- set position_class = 'modal-' + position -%}
{%- set scrollable_class = 'modal-scrollable' if scrollable else '' -%}
{%- set all_classes = ['modal', size_class, position_class, scrollable_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}"
{% if id %}id="{{ modal_id }}"{% endif %}
role="dialog"
aria-modal="true"
aria-labelledby="{{ title_id }}">
<div class="modal-backdrop" onclick="try{this.closest('.modal').remove();}catch(_){}"></div>
<div class="modal-content">
{% if title or show_close %}
<div class="modal-header">
{% if title %}
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
{% endif %}
{% if show_close %}
{% from '_buttons.html' import close_button %}
{{ close_button() }}
{% endif %}
</div>
{% endif %}
<div class="modal-body">
{{ content|safe }}
</div>
{% if footer_buttons %}
<div class="modal-footer">
{% from '_buttons.html' import button_group %}
{{ button_group(footer_buttons, alignment='right') }}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{#
Confirm Dialog Macro
Parameters:
- title (str): Dialog title (default: 'Confirm')
- message (str): Confirmation message (required)
- confirm_text (str): Confirm button text (default: 'Confirm')
- cancel_text (str): Cancel button text (default: 'Cancel')
- confirm_variant (str): Confirm button variant (default: 'primary')
- on_confirm (str): JavaScript handler for confirm action (required)
- on_cancel (str): JavaScript handler for cancel (default: close modal)
- classes (str): Additional CSS classes
Examples:
{{ confirm_dialog(
message='Delete this deck?',
confirm_text='Delete',
confirm_variant='danger',
on_confirm='deleteDeck(123)'
) }}
#}
{% macro confirm_dialog(title='Confirm', message='', confirm_text='Confirm', cancel_text='Cancel', confirm_variant='primary', on_confirm='', on_cancel='', classes='') %}
{{ simple_modal(
title=title,
content='<p>' + message + '</p>',
footer_buttons=[
{'text': cancel_text, 'variant': 'secondary', 'onclick': on_cancel if on_cancel else "this.closest('.modal').remove()"},
{'text': confirm_text, 'variant': confirm_variant, 'onclick': on_confirm}
],
size='sm',
classes='modal-confirm ' + classes
) }}
{% endmacro %}
{#
Form Modal Macro
Parameters: Similar to modal() plus:
- form_action (str): Form action URL (hx-post or action)
- form_method (str): 'post', 'get' (default: 'post')
- use_htmx (bool): Use HTMX for form submission (default: True)
- hx_target (str): HTMX target selector (default: 'closest .modal')
- hx_swap (str): HTMX swap strategy (default: 'outerHTML')
- submit_text (str): Submit button text (default: 'Submit')
- cancel_text (str): Cancel button text (default: 'Cancel')
- form_attrs (str): Additional form attributes
Content Blocks:
- body: Form fields (required)
Examples:
{% call form_modal(
title='Add Card',
form_action='/build/add-card',
submit_text='Add',
hx_target='#deck-list'
) %}
{% block body %}
<input type="text" name="card_name" placeholder="Card name" />
<input type="number" name="quantity" value="1" />
{% endblock %}
{% endcall %}
#}
{% macro form_modal(title='', form_action='', form_method='post', use_htmx=True, hx_target='closest .modal', hx_swap='outerHTML', submit_text='Submit', cancel_text='Cancel', size='md', classes='', form_attrs='') %}
{%- set modal_id = 'modal-form-' ~ title|lower|replace(' ', '-') -%}
{%- set title_id = modal_id + '-title' -%}
{%- set size_class = 'modal-' + size -%}
{%- set all_classes = ['modal', 'modal-form', size_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}"
id="{{ modal_id }}"
role="dialog"
aria-modal="true"
aria-labelledby="{{ title_id }}">
<div class="modal-backdrop" onclick="try{this.closest('.modal').remove();}catch(_){}"></div>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
{% from '_buttons.html' import close_button %}
{{ close_button() }}
</div>
<form {% if use_htmx %}hx-post="{{ form_action }}" hx-target="{{ hx_target }}" hx-swap="{{ hx_swap }}"{% else %}action="{{ form_action }}" method="{{ form_method }}"{% endif %} {{ form_attrs|safe }}>
<div class="modal-body">
{{ caller.body() }}
</div>
<div class="modal-footer">
{% from '_buttons.html' import button %}
{{ button(cancel_text, variant='secondary', onclick="this.closest('.modal').remove()") }}
{{ button(submit_text, type='submit', variant='primary') }}
</div>
</form>
</div>
</div>
{% endmacro %}
{#
Alert Modal Macro
Parameters:
- title (str): Alert title (default: 'Alert')
- message (str): Alert message (required)
- type (str): 'info', 'success', 'warning', 'error' (default: 'info')
- ok_text (str): OK button text (default: 'OK')
- on_ok (str): JavaScript handler for OK button (default: close modal)
- classes (str): Additional CSS classes
Examples:
{{ alert_modal(
title='Success',
message='Deck saved successfully!',
type='success'
) }}
{{ alert_modal(
title='Error',
message='Failed to save deck. Please try again.',
type='error'
) }}
#}
{% macro alert_modal(title='Alert', message='', type='info', ok_text='OK', on_ok='', classes='') %}
{%- set type_class = 'modal-alert-' + type -%}
{{ simple_modal(
title=title,
content='<div class="alert-icon alert-icon-' + type + '"></div><p>' + message + '</p>',
footer_buttons=[
{'text': ok_text, 'variant': 'primary', 'onclick': on_ok if on_ok else "this.closest('.modal').remove()"}
],
size='sm',
classes='modal-alert ' + type_class + ' ' + classes,
show_close=False
) }}
{% endmacro %}
{# CSS Classes Reference #}
{#
Modal Sizes:
- .modal-sm (480px max-width)
- .modal-md (620px max-width, default)
- .modal-lg (720px max-width)
- .modal-xl (960px max-width)
Modal Position:
- .modal-center (vertically centered, default)
- .modal-top (aligned to top with padding)
Modal Modifiers:
- .modal-scrollable (allows body scrolling)
- .modal-form (form-specific styling)
- .modal-confirm (confirmation dialog styling)
- .modal-alert (alert dialog styling)
- .modal-alert-info (blue theme)
- .modal-alert-success (green theme)
- .modal-alert-warning (yellow theme)
- .modal-alert-error (red theme)
Modal Structure:
- .modal (outer container, fixed positioning)
- .modal-backdrop (backdrop overlay)
- .modal-content (content box)
- .modal-header (header with title and close button)
- .modal-title (h2 title)
- .modal-body (main content area)
- .modal-footer (action buttons)
#}
{# JavaScript Helper Functions #}
{#
These functions should be included in a global JavaScript file or inline script:
// Open modal by ID
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
}
// Close modal by ID or element
function closeModal(modalOrId) {
const modal = typeof modalOrId === 'string'
? document.getElementById(modalOrId)
: modalOrId;
if (modal) {
modal.remove();
// Check if any other modals are open
if (!document.querySelector('.modal')) {
document.body.style.overflow = '';
}
}
}
// Close all modals
function closeAllModals() {
document.querySelectorAll('.modal').forEach(modal => modal.remove());
document.body.style.overflow = '';
}
#}

View file

@ -0,0 +1,399 @@
{# Panel/Tile Component Library #}
{# Usage: {{ import '_panels.html' }} then call panel macros #}
{#
Panel Container Macro
Parameters:
- title (str): Panel title (optional)
- variant (str): 'default', 'alt', 'dark', 'bordered' (default: 'default')
- padding (str): 'none', 'sm', 'md', 'lg' (default: 'md')
- classes (str): Additional CSS classes
- attrs (str): Additional HTML attributes
Content Block:
- header: Optional custom header (overrides title)
- body: Panel content (required)
- footer: Optional footer content
Examples:
{% call panel(title='Deck Stats') %}
{% block body %}
<p>Cards: 100</p>
{% endblock %}
{% endcall %}
{% call panel(variant='alt', padding='lg') %}
{% block body %}
<p>Content here</p>
{% endblock %}
{% endcall %}
#}
{% macro panel(title='', variant='default', padding='md', classes='', attrs='') %}
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
{%- set padding_class = 'panel-padding-' + padding -%}
{%- set all_classes = ['panel', variant_class, padding_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" {{ attrs|safe }}>
{% if caller.header is defined %}
{{ caller.header() }}
{% elif title %}
<div class="panel-header">
<h3 class="panel-title">{{ title }}</h3>
</div>
{% endif %}
<div class="panel-body">
{{ caller.body() }}
</div>
{% if caller.footer is defined %}
<div class="panel-footer">
{{ caller.footer() }}
</div>
{% endif %}
</div>
{% endmacro %}
{#
Simple Panel Macro (no block structure)
Parameters: Same as panel() plus:
- content (str): Body HTML content
Examples:
{{ simple_panel(title='Welcome', content='<p>Hello, user!</p>') }}
{{ simple_panel(content='<p>No title panel</p>', variant='alt') }}
#}
{% macro simple_panel(title='', content='', variant='default', padding='md', classes='', attrs='') %}
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
{%- set padding_class = 'panel-padding-' + padding -%}
{%- set all_classes = ['panel', variant_class, padding_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" {{ attrs|safe }}>
{% if title %}
<div class="panel-header">
<h3 class="panel-title">{{ title }}</h3>
</div>
{% endif %}
<div class="panel-body">
{{ content|safe }}
</div>
</div>
{% endmacro %}
{#
Info Panel Macro (with icon and optional action)
Parameters:
- icon (str): Icon HTML or character
- title (str): Panel title (required)
- content (str): Panel content (required)
- type (str): 'info', 'success', 'warning', 'error' (default: 'info')
- action_text (str): Optional action button text
- action_href (str): Action button URL
- action_onclick (str): Action button onclick handler
- classes (str): Additional CSS classes
Examples:
{{ info_panel(
icon='',
title='Setup Required',
content='Please run the setup process before building decks.',
type='info',
action_text='Run Setup',
action_href='/setup'
) }}
#}
{% macro info_panel(icon='', title='', content='', type='info', action_text='', action_href='', action_onclick='', classes='') %}
{%- set type_class = 'panel-info-' + type -%}
{%- set all_classes = ['panel', 'panel-info', type_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
<div class="panel-info-content">
{% if icon %}
<div class="panel-info-icon">{{ icon|safe }}</div>
{% endif %}
<div class="panel-info-text">
{% if title %}
<h4 class="panel-info-title">{{ title }}</h4>
{% endif %}
<div class="panel-info-message">{{ content|safe }}</div>
</div>
</div>
{% if action_text %}
<div class="panel-info-action">
{% from '_buttons.html' import button %}
{{ button(action_text, href=action_href, onclick=action_onclick, variant='primary', size='sm') }}
</div>
{% endif %}
</div>
{% endmacro %}
{#
Stat Panel Macro (for displaying key metrics)
Parameters:
- label (str): Stat label (required)
- value (str or int): Stat value (required)
- sublabel (str): Optional secondary label
- icon (str): Optional icon
- variant (str): 'default', 'primary', 'success', 'warning', 'error' (default: 'default')
- classes (str): Additional CSS classes
Examples:
{{ stat_panel('Total Cards', value=100) }}
{{ stat_panel('Mana Value', value='3.2', sublabel='Average', icon='⚡') }}
#}
{% macro stat_panel(label, value, sublabel='', icon='', variant='default', classes='') %}
{%- set variant_class = 'panel-stat-' + variant if variant != 'default' else '' -%}
{%- set all_classes = ['panel', 'panel-stat', variant_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if icon %}
<div class="panel-stat-icon">{{ icon|safe }}</div>
{% endif %}
<div class="panel-stat-content">
<div class="panel-stat-value">{{ value }}</div>
<div class="panel-stat-label">{{ label }}</div>
{% if sublabel %}
<div class="panel-stat-sublabel">{{ sublabel }}</div>
{% endif %}
</div>
</div>
{% endmacro %}
{#
Collapsible Panel Macro
Parameters:
- title (str): Panel title (required)
- id (str): Panel HTML id (auto-generated if not provided)
- expanded (bool): Initially expanded (default: False)
- variant (str): Panel variant (default: 'default')
- padding (str): Panel padding (default: 'md')
- classes (str): Additional CSS classes
Content Block:
- body: Panel content (required)
Examples:
{% call collapsible_panel(title='Advanced Options', expanded=False) %}
{% block body %}
<p>Advanced settings here</p>
{% endblock %}
{% endcall %}
#}
{% macro collapsible_panel(title, id='', expanded=False, variant='default', padding='md', classes='') %}
{%- set panel_id = id if id else 'panel-' + title|lower|replace(' ', '-') -%}
{%- set content_id = panel_id + '-content' -%}
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
{%- set padding_class = 'panel-padding-' + padding -%}
{%- set expanded_class = 'panel-expanded' if expanded else 'panel-collapsed' -%}
{%- set all_classes = ['panel', 'panel-collapsible', variant_class, padding_class, expanded_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" id="{{ panel_id }}">
<button type="button"
class="panel-toggle"
aria-expanded="{{ 'true' if expanded else 'false' }}"
aria-controls="{{ content_id }}"
onclick="togglePanel('{{ panel_id }}')">
<span class="panel-toggle-icon"></span>
<span class="panel-title">{{ title }}</span>
</button>
<div class="panel-body panel-collapse-content"
id="{{ content_id }}"
{% if not expanded %}style="display:none;"{% endif %}>
{{ caller.body() }}
</div>
</div>
{% endmacro %}
{#
Grid Container Macro (for laying out multiple panels)
Parameters:
- columns (int or str): Number of columns (1, 2, 3, 4, 'auto') (default: 'auto')
- gap (str): Grid gap (default: '1rem')
- classes (str): Additional CSS classes
Content Block:
- body: Grid items (panels or other content)
Examples:
{% call grid_container(columns=3) %}
{% block body %}
{{ stat_panel('Stat 1', 100) }}
{{ stat_panel('Stat 2', 200) }}
{{ stat_panel('Stat 3', 300) }}
{% endblock %}
{% endcall %}
#}
{% macro grid_container(columns='auto', gap='1rem', classes='') %}
{%- set columns_class = 'panel-grid-cols-' + (columns|string) -%}
{%- set all_classes = ['panel-grid', columns_class, classes]|select|join(' ') -%}
<div class="{{ all_classes }}" style="gap: {{ gap }};">
{{ caller.body() }}
</div>
{% endmacro %}
{#
Empty State Panel Macro
Parameters:
- icon (str): Icon HTML or character
- title (str): Empty state title (required)
- message (str): Empty state message (required)
- action_text (str): Optional action button text
- action_href (str): Action button URL
- action_onclick (str): Action button onclick handler
- classes (str): Additional CSS classes
Examples:
{{ empty_state_panel(
icon='📋',
title='No Decks Found',
message='You haven\'t created any decks yet. Start building your first deck!',
action_text='Build Deck',
action_href='/build'
) }}
#}
{% macro empty_state_panel(icon='', title='', message='', action_text='', action_href='', action_onclick='', classes='') %}
{%- set all_classes = ['panel', 'panel-empty-state', classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if icon %}
<div class="panel-empty-icon">{{ icon|safe }}</div>
{% endif %}
{% if title %}
<h3 class="panel-empty-title">{{ title }}</h3>
{% endif %}
{% if message %}
<p class="panel-empty-message">{{ message }}</p>
{% endif %}
{% if action_text %}
<div class="panel-empty-action">
{% from '_buttons.html' import button %}
{{ button(action_text, href=action_href, onclick=action_onclick, variant='primary') }}
</div>
{% endif %}
</div>
{% endmacro %}
{#
Loading Panel Macro
Parameters:
- message (str): Loading message (default: 'Loading...')
- spinner (bool): Show spinner animation (default: True)
- classes (str): Additional CSS classes
Examples:
{{ loading_panel() }}
{{ loading_panel(message='Building deck...', spinner=True) }}
#}
{% macro loading_panel(message='Loading...', spinner=True, classes='') %}
{%- set all_classes = ['panel', 'panel-loading', classes]|select|join(' ') -%}
<div class="{{ all_classes }}">
{% if spinner %}
<div class="panel-loading-spinner" aria-hidden="true"></div>
{% endif %}
<div class="panel-loading-message">{{ message }}</div>
</div>
{% endmacro %}
{# CSS Classes Reference #}
{#
Panel Base:
- .panel (base panel class)
Panel Variants:
- .panel-default (default background, var(--panel))
- .panel-alt (alternate background, var(--panel-alt))
- .panel-dark (dark background, #0f1115)
- .panel-bordered (bordered, no background)
Panel Padding:
- .panel-padding-none (no padding)
- .panel-padding-sm (0.5rem)
- .panel-padding-md (0.75rem, default)
- .panel-padding-lg (1.5rem)
Panel Structure:
- .panel-header (header section)
- .panel-title (title text, h3)
- .panel-body (content section)
- .panel-footer (footer section)
Info Panel:
- .panel-info (info panel container)
- .panel-info-info (blue theme)
- .panel-info-success (green theme)
- .panel-info-warning (yellow theme)
- .panel-info-error (red theme)
- .panel-info-content (content wrapper)
- .panel-info-icon (icon container)
- .panel-info-text (text wrapper)
- .panel-info-title (info title, h4)
- .panel-info-message (info message)
- .panel-info-action (action button wrapper)
Stat Panel:
- .panel-stat (stat panel container)
- .panel-stat-default, .panel-stat-primary, etc. (color variants)
- .panel-stat-icon (stat icon)
- .panel-stat-content (stat content wrapper)
- .panel-stat-value (stat value, large)
- .panel-stat-label (stat label)
- .panel-stat-sublabel (optional secondary label)
Collapsible Panel:
- .panel-collapsible (collapsible panel)
- .panel-expanded (expanded state)
- .panel-collapsed (collapsed state)
- .panel-toggle (toggle button)
- .panel-toggle-icon (chevron/arrow icon)
- .panel-collapse-content (collapsible content)
Panel Grid:
- .panel-grid (grid container)
- .panel-grid-cols-auto (auto columns)
- .panel-grid-cols-1, .panel-grid-cols-2, etc. (fixed columns)
Empty State:
- .panel-empty-state (empty state container)
- .panel-empty-icon (empty state icon)
- .panel-empty-title (empty state title, h3)
- .panel-empty-message (empty state message, p)
- .panel-empty-action (action button wrapper)
Loading Panel:
- .panel-loading (loading panel)
- .panel-loading-spinner (spinner animation)
- .panel-loading-message (loading message text)
#}
{# JavaScript Helper Functions #}
{#
These functions should be included in a global JavaScript file or inline script:
// Toggle collapsible panel
function togglePanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
const button = panel.querySelector('.panel-toggle');
const content = panel.querySelector('.panel-collapse-content');
const isExpanded = button.getAttribute('aria-expanded') === 'true';
// Toggle state
button.setAttribute('aria-expanded', !isExpanded);
content.style.display = isExpanded ? 'none' : 'block';
panel.classList.toggle('panel-expanded');
panel.classList.toggle('panel-collapsed');
}
#}

View file

@ -1,9 +1,9 @@
<div id="deck-summary" data-summary>
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<hr class="summary-divider" />
<h4>Deck Summary</h4>
<section style="margin-top:.5rem;">
<section class="summary-section">
<h5>Card Types</h5>
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
<div class="summary-view-controls">
<span class="muted">View:</span>
<div class="seg" role="tablist" aria-label="Type view">
<button type="button" class="seg-btn" data-view="list" aria-selected="true" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.remove('hidden');thumbs.classList.add('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=thumbs]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','list');}catch(e){}})(this)">List</button>
@ -36,7 +36,7 @@
</style>
<div id="typeview-list" class="typeview">
{% for t in tb.order %}
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
<div class="summary-type-heading">
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
</div>
{% set clist = tb.cards.get(t, []) %}
@ -85,13 +85,13 @@
{% endfor %}
</div>
{% else %}
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
<div class="muted mb-3">No cards in this type.</div>
{% endif %}
{% endfor %}
</div>
<div id="typeview-thumbs" class="typeview hidden">
{% for t in tb.order %}
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
<div class="summary-type-heading">
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
</div>
{% set clist = tb.cards.get(t, []) %}
@ -111,8 +111,8 @@
{% endfor %}
{% endif %}
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
<img class="card-thumb" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
<img class="card-thumb" loading="lazy" decoding="async" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
sizes="(max-width: 1200px) 160px, 240px" />
<div class="count-badge">{{ cnt }}x</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
@ -124,7 +124,7 @@
</div>
</div>
{% else %}
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
<div class="muted mb-3">No cards in this type.</div>
{% endif %}
{% endfor %}
</div>
@ -138,40 +138,40 @@
<!-- Land Summary -->
{% set land = summary.land_summary if summary else None %}
{% if land %}
<section style="margin-top:1rem;">
<section class="summary-section-lg">
<h5>Land Summary</h5>
<div class="muted" style="font-weight:600; margin-bottom:.35rem;">
<div class="muted summary-type-heading mb-1">
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
</div>
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:flex-start;">
<div class="deck-metrics-wrap">
<div class="muted">Traditional land slots: <strong>{{ land.traditional or 0 }}</strong></div>
<div class="muted">MDFC land additions: <strong>{{ land.dfc_lands or 0 }}</strong></div>
<div class="muted">Total with MDFCs: <strong>{{ land.with_dfc or land.traditional or 0 }}</strong></div>
</div>
{% if land.dfc_cards %}
<details style="margin-top:.5rem;">
<details class="mt-2">
<summary>MDFC mana sources ({{ land.dfc_cards|length }})</summary>
<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
<ul class="land-breakdown-list">
{% for card in land.dfc_cards %}
{% set extra = card.adds_extra_land or card.counts_as_extra %}
{% set colors = card.colors or [] %}
<li class="muted" style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:flex-start;">
<span class="chip"><span class="dot" style="background:#10b981;"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
<li class="muted land-breakdown-item">
<span class="chip"><span class="dot dot-green"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
<span>Colors: {{ colors|join(', ') if colors else '' }}</span>
{% if extra %}
<span class="chip" style="background:#0f172a; border-color:#34d399; color:#a7f3d0;">{{ card.note or 'Adds extra land slot' }}</span>
<span class="chip land-note-chip-expand">{{ card.note or 'Adds extra land slot' }}</span>
{% else %}
<span class="chip" style="background:#111827; border-color:#60a5fa; color:#bfdbfe;">{{ card.note or 'Counts as land slot' }}</span>
<span class="chip land-note-chip-counts">{{ card.note or 'Counts as land slot' }}</span>
{% endif %}
{% if card.faces %}
<ul style="list-style:none; padding:0; margin:.2rem 0 0; display:grid; gap:.15rem; flex:1 0 100%;">
<ul class="land-breakdown-subs">
{% for face in card.faces %}
{% set face_name = face.get('face') or face.get('faceName') or 'Face' %}
{% set face_type = face.get('type') or '' %}
{% set mana_cost = face.get('mana_cost') %}
{% set mana_value = face.get('mana_value') %}
{% set produces = face.get('produces_mana') %}
<li style="font-size:0.85rem; color:#e5e7eb; opacity:.85;">
<li class="land-breakdown-sub">
<span>{{ face_name }}</span>
<span>— {{ face_type }}</span>
{% if mana_cost %}<span>• Mana Cost: {{ mana_cost }}</span>{% endif %}
@ -190,27 +190,27 @@
{% endif %}
<!-- Mana Overview Row: Pips • Sources • Curve -->
<section style="margin-top:1rem;">
<section class="summary-section-lg">
<details class="analytics-accordion" id="mana-overview-accordion" data-lazy-load data-analytics-type="mana">
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
<summary class="combo-summary">
<span>Mana Overview</span>
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(pips • sources • curve)</span>
<span class="muted text-xs font-normal ml-2">(pips • sources • curve)</span>
</summary>
<div class="analytics-content" style="margin-top:.75rem;">
<h5 style="margin:0 0 .5rem 0;">Mana Overview</h5>
<div class="analytics-content mt-3">
<h5 class="mt-0 mb-2">Mana Overview</h5>
{% set deck_colors = summary.colors or [] %}
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
<div class="mana-row">
<!-- Pips Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
<div class="mana-panel">
<div class="muted mana-panel-heading">Mana Pips (non-lands)</div>
{% set pd = summary.pip_distribution %}
{% if pd %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
<div class="chart-bars">
{% for color in colors %}
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
{% set pct = (w * 100) | int %}
<div style="text-align:center;" class="chart-column">
<div class="chart-column">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pc = pd['cards'] if 'cards' in pd else None %}
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
@ -224,14 +224,14 @@
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" style="cursor:pointer;" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" class="chart-svg" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set h = (pct * 1.0) | int %}
{% set bar_h = (h if h>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
<div class="muted mt-1">{{ color }}</div>
</div>
{% endfor %}
</div>
@ -241,10 +241,10 @@
</div>
<!-- Sources Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.35rem;">
<div class="muted" style="font-weight:600;">Mana Sources</div>
<label class="muted" style="font-size:12px; display:flex; align-items:center; gap:.35rem; cursor:pointer;">
<div class="mana-panel">
<div class="flex items-center justify-between gap-3 mb-1">
<div class="muted mana-panel-heading">Mana Sources</div>
<label class="muted text-xs form-label-icon">
<input type="checkbox" id="toggle-show-c" /> Show colorless (C)
</label>
</div>
@ -261,7 +261,7 @@
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
{% endfor %}
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
<div class="sources-bars" style="display:flex; gap:14px; align-items:flex-end; height:140px;">
<div class="sources-bars chart-bars">
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% set pct = (val * 100 / denom) | int %}
@ -273,31 +273,31 @@
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
<div style="text-align:center;" class="chart-column" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" style="cursor:pointer;" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<div class="chart-column" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" class="chart-svg" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
<div class="muted mt-1">{{ color }}</div>
</div>
{% endfor %}
</div>
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
<div class="muted mt-1">Total sources: {{ mg.total_sources or 0 }}</div>
{% else %}
<div class="muted">No mana source data.</div>
{% endif %}
</div>
<!-- Curve Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
<div class="mana-panel">
<div class="muted mana-panel-heading">Mana Curve (non-lands)</div>
{% set mc = summary.mana_curve %}
{% if mc %}
{% set ts = mc.total_spells or 0 %}
{% set denom = (ts if ts and ts > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
<div class="chart-bars">
{% for label in ['0','1','2','3','4','5','6+'] %}
{% set val = mc.get(label, 0) %}
{% set pct = (val * 100 / denom) | int %}
@ -308,18 +308,18 @@
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (100.0 * (val / denom)) %}
<div style="text-align:center;" class="chart-column">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" style="cursor:pointer;" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<div class="chart-column">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" class="chart-svg" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4" pointer-events="all"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
<div class="muted mt-1">{{ label }}</div>
</div>
{% endfor %}
</div>
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
<div class="muted mt-1">Total spells: {{ mc.total_spells or 0 }}</div>
{% else %}
<div class="muted">No curve data.</div>
{% endif %}
@ -330,17 +330,17 @@
</section>
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
<section style="margin-top:1rem;">
<section class="summary-section-lg">
<details class="analytics-accordion" id="test-hand-accordion" data-lazy-load data-analytics-type="testhand">
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
<summary class="combo-summary">
<span>Test Hand</span>
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(draw 7 random cards)</span>
<span class="muted text-xs font-normal ml-2">(draw 7 random cards)</span>
</summary>
<div class="analytics-content" style="margin-top:.75rem;">
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
<div class="analytics-content mt-3">
<h5 class="flex items-center gap-3 flex-wrap mt-0 mb-1">Test Hand
<span class="muted text-xs font-normal">Draw 7 at random (no repeats except for basic lands).</span>
</h5>
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; margin-bottom:.5rem;">
<div class="flex gap-2 items-center flex-wrap mb-2">
<button type="button" id="btn-new-hand">New Hand</button>
</div>
<div class="stack-wrap hand-row-overlap" id="test-hand">
@ -440,7 +440,7 @@
if (mid >= 2 && Math.abs(diff - (mid - 1)) < 0.001) { y += 2; }
div.style.setProperty('--ty', y + 'px');
div.innerHTML = (
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
'<img src="/api/images/normal/' + encodeURIComponent(name) + '" alt="' + name + '" data-card-name="' + name + '" />'
);
grid.appendChild(div);
});

View file

@ -109,8 +109,8 @@
<div class="commander-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
<div class="commander-thumb" style="flex:0 0 auto;">
<img
src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w"
src="{{ commander|card_image('small') }}"
srcset="{{ commander|card_image('small') }} 160w, {{ commander|card_image('normal') }} 488w"
sizes="(max-width: 600px) 120px, 160px"
alt="{{ commander }} image"
width="160" height="220"