From b7bfc4ca095f2a4a5dbf8d4122994223dbd6c3cc Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 7 Oct 2025 11:35:43 -0700 Subject: [PATCH 1/3] feat(ui): add debounce helper and skeleton polish --- CHANGELOG.md | 7 +- code/web/static/app.js | 146 ++- code/web/static/styles.css | 23 + code/web/templates/build/_alternatives.html | 4 +- code/web/templates/build/_new_deck_modal.html | 4 +- .../web/templates/build/_stage_navigator.html | 2 +- code/web/templates/build/index.html | 2 +- code/web/templates/themes/picker.html | 3 +- config/themes/theme_list.json | 977 +++++------------- 9 files changed, 449 insertions(+), 719 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f21bf..43ff9aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,14 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -- _No changes yet_ +- Phase 1 responsiveness tweaks: shared HTMX debounce helper, deferred skeleton microcopy, and containment rules for long card lists. ### Added -- _None_ +- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel. ### Changed -- _None_ +- Commander quick-start and theme picker searches route through a centralized `data-hx-debounce` helper so rapid keystrokes coalesce into a single HTMX request. +- Card grids and alternative lists opt into `content-visibility`/`contain` to reduce layout churn on large decks. ### Fixed - _None_ diff --git a/code/web/static/app.js b/code/web/static/app.js index eb129dc..2156977 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -136,19 +136,141 @@ } addFocusVisible(); - // Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers - function showSkeletons(container){ - (container || document).querySelectorAll('[data-skeleton]') - .forEach(function(el){ el.classList.add('is-loading'); }); + // Skeleton utility: defer placeholders until the request lasts long enough to be noticeable + var SKELETON_DELAY_DEFAULT = 400; + var skeletonTimers = new WeakMap(); + function gatherSkeletons(root){ + if (!root){ return []; } + var list = []; + var scope = (root.nodeType === 9) ? root.documentElement : root; + if (scope && scope.matches && scope.hasAttribute('data-skeleton')){ + list.push(scope); + } + if (scope && scope.querySelectorAll){ + scope.querySelectorAll('[data-skeleton]').forEach(function(el){ + if (list.indexOf(el) === -1){ list.push(el); } + }); + } + return list; } - function hideSkeletons(container){ - (container || document).querySelectorAll('[data-skeleton]') - .forEach(function(el){ el.classList.remove('is-loading'); }); + function scheduleSkeleton(el){ + var delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10); + var delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr); + clearSkeleton(el, false); + var timer = setTimeout(function(){ + el.classList.add('is-loading'); + el.setAttribute('aria-busy', 'true'); + skeletonTimers.set(el, null); + }, delay); + skeletonTimers.set(el, timer); + } + function clearSkeleton(el, removeBusy){ + var timer = skeletonTimers.get(el); + if (typeof timer === 'number'){ + clearTimeout(timer); + } + skeletonTimers.delete(el); + el.classList.remove('is-loading'); + if (removeBusy !== false){ el.removeAttribute('aria-busy'); } + } + function showSkeletons(context){ + gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); }); + } + function hideSkeletons(context){ + gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el); }); } window.skeletons = { show: showSkeletons, hide: hideSkeletons }; - document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); }); - document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); }); + document.addEventListener('htmx:beforeRequest', function(e){ + var detail = e && e.detail ? e.detail : {}; + var target = detail.target || detail.elt || e.target; + showSkeletons(target); + }); + document.addEventListener('htmx:afterSwap', function(e){ + var detail = e && e.detail ? e.detail : {}; + var target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + document.addEventListener('htmx:afterRequest', function(e){ + var detail = e && e.detail ? e.detail : {}; + var target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + + // Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce) + var hxDebounceGroups = new Map(); + function dispatchHtmx(el, evtName){ + if (!el) return; + if (window.htmx && typeof window.htmx.trigger === 'function'){ + window.htmx.trigger(el, evtName); + } else { + try { el.dispatchEvent(new Event(evtName, { bubbles: true })); } catch(_){ } + } + } + function bindHtmxDebounce(el){ + if (!el || el.__hxDebounceBound) return; + el.__hxDebounceBound = true; + var delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10); + var delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw); + var eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input'; + var events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean); + if (!events.length){ events = ['input']; } + var trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput'; + var group = el.getAttribute('data-hx-debounce-group') || ''; + var flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase(); + var flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true'); + function clearTimer(){ + if (el.__hxDebounceTimer){ + clearTimeout(el.__hxDebounceTimer); + el.__hxDebounceTimer = null; + } + } + function schedule(){ + clearTimer(); + if (group){ + var prev = hxDebounceGroups.get(group); + if (prev && prev !== el && prev.__hxDebounceTimer){ + clearTimeout(prev.__hxDebounceTimer); + prev.__hxDebounceTimer = null; + } + hxDebounceGroups.set(group, el); + } + el.__hxDebounceTimer = setTimeout(function(){ + el.__hxDebounceTimer = null; + dispatchHtmx(el, trigger); + }, delay); + } + events.forEach(function(evt){ + el.addEventListener(evt, schedule, { passive: true }); + }); + if (flushOnBlur){ + el.addEventListener('blur', function(){ + if (el.__hxDebounceTimer){ + clearTimer(); + dispatchHtmx(el, trigger); + } + }); + } + el.addEventListener('htmx:beforeRequest', clearTimer); + } + function initHtmxDebounce(root){ + var scope = root || document; + if (scope === document){ scope = document.body || document; } + if (!scope) return; + var seen = new Set(); + function collect(candidate){ + if (!candidate || seen.has(candidate)) return; + seen.add(candidate); + bindHtmxDebounce(candidate); + } + if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){ + collect(scope); + } + if (scope.querySelectorAll){ + scope.querySelectorAll('[data-hx-debounce]').forEach(collect); + } + } + window.initHtmxDebounce = initHtmxDebounce; // Example: persist "show skipped" toggle if present document.addEventListener('change', function(e){ @@ -172,7 +294,8 @@ hydrateProgress(document); syncShowSkipped(document); initCardFilters(document); - initVirtualization(document); + initVirtualization(document); + initHtmxDebounce(document); }); // Hydrate progress bars with width based on data-pct @@ -200,7 +323,8 @@ hydrateProgress(e.target); syncShowSkipped(e.target); initCardFilters(e.target); - initVirtualization(e.target); + initVirtualization(e.target); + initHtmxDebounce(e.target); }); // Scroll a card-tile into view (cooperates with virtualization by re-rendering first) diff --git a/code/web/static/styles.css b/code/web/static/styles.css index b0f6cc9..9f800da 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -246,6 +246,23 @@ small, .muted{ color: var(--muted); } display: none; } [data-skeleton].is-loading::after{ display:block; } +[data-skeleton].is-loading::before{ + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} +[data-skeleton][data-skeleton-label=""]::before{ content:''; } @keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } /* Banner */ @@ -268,6 +285,9 @@ small, .muted{ color: var(--muted); } justify-content: start; /* pack as many as possible per row */ /* Prevent scroll chaining bounce that can cause flicker near bottom */ overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; } @media (max-width: 420px){ .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -293,6 +313,9 @@ small, .muted{ color: var(--muted); } .card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } .card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } +.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } +.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } + /* Shared ownership badge for card tiles and stacked images */ .owned-badge{ position:absolute; diff --git a/code/web/templates/build/_alternatives.html b/code/web/templates/build/_alternatives.html index 36e6ee4..6e86bf9 100644 --- a/code/web/templates/build/_alternatives.html +++ b/code/web/templates/build/_alternatives.html @@ -3,7 +3,7 @@ { 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] } ] #} -
+
Alternatives {% set toggle_q = '0' if require_owned else '1' %} @@ -18,7 +18,7 @@ {% if not items or items|length == 0 %}
No alternatives found{{ ' (owned only)' if require_owned else '' }}.
{% else %} -
    +
      {% for it in items %} {% set badge = '✔' if it.owned else '✖' %} {% set title = 'Owned' if it.owned else 'Not owned' %} diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index a97bc1a..f73459f 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -20,7 +20,9 @@ Commander + 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" /> Start typing to see matches, then select one to load themes.
      diff --git a/code/web/templates/build/_stage_navigator.html b/code/web/templates/build/_stage_navigator.html index 6f74a34..db7f351 100644 --- a/code/web/templates/build/_stage_navigator.html +++ b/code/web/templates/build/_stage_navigator.html @@ -2,7 +2,7 @@ {% set labels = ['Choose Commander','Tags & Bracket','Ideal Counts','Review','Build'] %} {% set index = step_index if step_index is defined else i if i is defined else 1 %} {% set total = step_total if step_total is defined else n if n is defined else 5 %} -
-
+
diff --git a/code/web/templates/themes/picker.html b/code/web/templates/themes/picker.html index ee50e8f..c596ad5 100644 --- a/code/web/templates/themes/picker.html +++ b/code/web/templates/themes/picker.html @@ -4,7 +4,8 @@
+ hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="debouncedinput change" name="q" + data-hx-debounce="260" data-hx-debounce-events="input,keyup" data-hx-debounce-flush="blur" data-hx-debounce-group="theme-search" />