Context
The deals grid currently renders all 186 cards on a single page load (js/search.js:158 loops over the entire filtered set, no pagination or batching). Combined with:
- A 20ms-per-card staggered entrance animation that runs up to ~3.7s on the last card
- A 186-DOM-node page that buries the footer and gives no sense of "how much is left"
- No sorting UI anywhere — deals render in JSONL-file order, which is arbitrary to the user
- No back-button semantics for filter state (filters use
history.replaceState)
- No
prefers-reduced-motion support (a11y bug, not polish)
This is a UX and a11y improvement task — the codebase has infinite scroll nowhere; this issue replaces the unbounded full-render with explicit numbered pagination + a sort <select>.
Goal
- Render 24 deals per page in a numbered pagination control below
#deals-grid.
- Add a sort
<select> with 4 sort modes (no recent — JSONL has no added field).
- Preserve existing URL-state sync, ARIA wiring, filter chips, and skeleton first-paint.
- Restore back-button semantics for page navigation while keeping
replaceState for filter toggles (filters don't deserve history entries; pages do).
- Respect
prefers-reduced-motion.
- Add debug logging behind a
window.__DEVcheap_DEBUG flag for QA troubleshooting.
Design contracts
- URL state:
?category=AI&recommended=1&sort=expiring&page=3 round-trips
page via history.pushState (back button works)
sort via history.replaceState
category / filter params via existing replaceState (unchanged)
- Changing sort or any filter resets
currentPage = 1 (a paginated slice of a different filter set isn't the same page).
#deals-count updates to Showing 1–24 of 186 deals (already has aria-live="polite", announces to AT).
- Pagination hidden when only one page of results (filtered set ≤
PAGE_SIZE).
- Windowed page numbers:
1 … 4 5 [6] 7 8 … 12 (Google-style, max ~7 visible).
aria-current="page" on current page button (WCAG 2.1 pattern).
- Disabled
‹/› buttons also set aria-disabled="true".
- Reduced motion: smooth scroll on page change becomes instant when
matchMedia('(prefers-reduced-motion: reduce)') matches.
- No framework added — vanilla JS, ES module, matches existing
search.js style.
Field names (verified from data/deals.jsonl)
| Sort key |
Source field |
Notes |
default |
(JSONL order) |
Current behavior, preserved |
expiring |
deal.expires (ISO date string or null) |
null/no-expiry sort to Infinity (end of list). Date parsing on new Date(deal.expires). |
alphabetical |
deal.name |
localeCompare lowercase |
recommended |
deal.tags.toLowerCase().includes('recommended') |
Mirrors existing logic at search.js:181 |
Note: recent sort was originally considered but dropped — data/deals.jsonl does not contain an added / created timestamp field. If added later, this sort key can be reintroduced.
Decisions on open questions (principal-engineer calls)
pushState for pagination, replaceState for filters (asymmetric, intentional). Justification: back-button should walk pages but not unwind every filter click. Documented in code comments.
- Filter clear → reset to page 1 and clear
?page= from URL. Done in the existing "Clear all filters" handler at search.js:660.
- No skeleton re-render on page change — 24 cards render faster than the network round-trip a skeleton implies. Skeletons stay only on the initial boot fetch (current behavior).
- Print stylesheet deferred — nice-to-have, out of scope for this issue. Will file a follow-up if needed.
- No
<noscript> fallback added here — separately tracked; this issue is pagination+sort only.
File-by-file changes
index.html
- Wrap
#deals-count in a .deals-toolbar flex container with a sort <select> (#sort-select) and <label for="sort-select">.
- Insert
<nav id="pagination" hidden aria-label="Deal pages"> between #deals-grid and p.results-hint.
js/search.js
- Add module-level state:
PAGE_SIZE = 24, currentPage = 1, currentSort = 'default'.
- Add
sortDeals(deals, sortKey) helper (pure, returns new array).
- Add
computePageWindow(current, total) → ['1', '...', '4', '5', '6', ..., '12'].
- Add
renderPagination(totalCount, page) to render windowed nav, returns early if totalPages <= 1.
- Modify
renderDeals() to:
- Apply
sortDeals(filtered, currentSort) after existing filter chain.
- Slice to
currentPage window.
- Update
#deals-count to "Showing X–Y of Z deals".
- Cap
animationDelay at in-page index (max ~480ms per page instead of scaling with total).
- Call
renderPagination(sorted.length, currentPage).
- Category tab click (search.js:470) and filter chip click (search.js:509) and active-filter-badge removal (search.js:710): set
currentPage = 1 and call updatePageURL(1) before renderDeals().
- New pagination click handler (delegated on
#pagination): pushState({ page }, '', url-with-page-param), set currentPage, call renderDeals(), scroll to #deals (smooth or auto per reduced-motion).
- New sort
<select> change handler: set currentSort, reset currentPage = 1, updateSortURL(sort), renderDeals().
- Extend boot URL-param parsing (search.js:596–638): read
page and sort, apply to currentPage / currentSort, sync <select> value.
- Add
window.addEventListener('popstate', ...) to restore page/sort from URL on back/forward.
- Expose
renderDeals, sortDeals, computePageWindow, renderPagination on window for test access (matches existing window.boot, window.activeCategories pattern).
- Add debug logging under
if (window.__DEVcheap_DEBUG):
- On page change:
[pagination] page ${prev} → ${next}, total ${totalCount}
- On sort change:
[sort] ${prev} → ${next}
- On boot URL restore:
URL params restored → page=${page}, sort=${sort}
- On filter reset:
[filters] cleared, reset to page 1
- Add keyboard handler for
.deal-card-overflow (Enter/Space) — this is a pre-existing WCAG gap surfaced during code review, fixed now while we're touching search.js.
css/style.css
.deals-toolbar — flex, justify-content: space-between, align-items: baseline, flex-wrap: wrap, gap: 12px.
.sort-wrap, .sort-label, .sort-select — styled to match existing input tokens (--bg-surface, --border, --radius-md).
.pagination — flex, centered, gap: 6px, margin-top: 28px, flex-wrap: wrap.
.page-btn — min-width: 36px, height: 36px, border-radius: var(--radius-md), matches .cat-btn styling.
.page-btn[aria-current="page"] — primary accent background, white text.
.page-btn[disabled] — opacity: 0.4, cursor: not-allowed.
.page-ellipsis — padding: 0 4px, color: var(--text-tertiary), aria-hidden="true".
@media (max-width: 640px) — .deals-toolbar stacks vertically, .pagination gap shrinks to 4px.
@media (prefers-reduced-motion: reduce) — disables cardIn, pulse, copyPop, badge-glow animations (also fixes the standalone a11y gap surfaced during code review).
tests/ui-interactions.test.js
New describe blocks:
Pagination controls:
- renders pagination when filtered total >
PAGE_SIZE
- hides pagination when ≤
PAGE_SIZE results
- clicking a page button updates
currentPage and re-renders
- clicking "Next" advances page; "Prev" retreats; disabled at ends
- clicking past the last page is no-op
- changing sort resets to page 1
- changing category resets to page 1
- changing filter chip resets to page 1
URLSearchParams reflects current page after page click (pushState)
popstate event restores currentPage from URL
aria-current="page" appears on exactly one button when paginated
- prev/next disabled buttons also have
aria-disabled="true"
Sort select:
- changing sort re-orders the visible window
- "alphabetical" sorts A→Z
- "expiring" puts deals with
expires nearest-soonest first, no-expiry at end
- "recommended" surfaces recommended-tagged deals first
- "default" preserves JSONL order
- sort + pagination compose (page boundary respected after sort change)
URLSearchParams reflects sort param
tests/html-structure.test.js
- Assert
<select id="sort-select"> with 4 expected <option>s (default/expiring/alphabetical/recommended).
- Assert
<label for="sort-select"> exists.
- Assert
<nav id="pagination" aria-label="Deal pages"> exists with hidden attribute set in initial static HTML.
tests/accessibility.test.js
- Assert
aria-current="page" on current page button (when paginated).
- Assert disabled pagination buttons have
aria-disabled="true".
- Assert
<nav aria-label="Deal pages"> exists.
- Assert sort
<select> has accessible name (label-for OR aria-label).
- (Bundled fix) assert
.deal-card-overflow responds to Enter/Space keyboard — covers the a11y fix bundled in this issue.
Validation
npm run validate:jsonl
npm test
npm run lint:html
npm run lint:css
Checklist for PR
Out of scope (follow-ups)
<noscript> fallback content for the grid
- Print stylesheet (render all filtered deals when printing)
- "Load More" alternative pattern
- Adding
added/created field to JSONL → re-evaluate recent sort
Context
The deals grid currently renders all 186 cards on a single page load (
js/search.js:158loops over the entire filtered set, no pagination or batching). Combined with:history.replaceState)prefers-reduced-motionsupport (a11y bug, not polish)This is a UX and a11y improvement task — the codebase has infinite scroll nowhere; this issue replaces the unbounded full-render with explicit numbered pagination + a sort
<select>.Goal
#deals-grid.<select>with 4 sort modes (norecent— JSONL has noaddedfield).replaceStatefor filter toggles (filters don't deserve history entries; pages do).prefers-reduced-motion.window.__DEVcheap_DEBUGflag for QA troubleshooting.Design contracts
?category=AI&recommended=1&sort=expiring&page=3round-tripspageviahistory.pushState(back button works)sortviahistory.replaceStatecategory/ filter params via existingreplaceState(unchanged)currentPage = 1(a paginated slice of a different filter set isn't the same page).#deals-countupdates toShowing 1–24 of 186 deals(already hasaria-live="polite", announces to AT).PAGE_SIZE).1 … 4 5 [6] 7 8 … 12(Google-style, max ~7 visible).aria-current="page"on current page button (WCAG 2.1 pattern).‹/›buttons also setaria-disabled="true".matchMedia('(prefers-reduced-motion: reduce)')matches.search.jsstyle.Field names (verified from
data/deals.jsonl)defaultexpiringdeal.expires(ISO date string ornull)null/no-expiry sort to Infinity (end of list). Date parsing onnew Date(deal.expires).alphabeticaldeal.namelocaleComparelowercaserecommendeddeal.tags.toLowerCase().includes('recommended')search.js:181Decisions on open questions (principal-engineer calls)
pushStatefor pagination,replaceStatefor filters (asymmetric, intentional). Justification: back-button should walk pages but not unwind every filter click. Documented in code comments.?page=from URL. Done in the existing "Clear all filters" handler atsearch.js:660.<noscript>fallback added here — separately tracked; this issue is pagination+sort only.File-by-file changes
index.html#deals-countin a.deals-toolbarflex container with a sort<select>(#sort-select) and<label for="sort-select">.<nav id="pagination" hidden aria-label="Deal pages">between#deals-gridandp.results-hint.js/search.jsPAGE_SIZE = 24,currentPage = 1,currentSort = 'default'.sortDeals(deals, sortKey)helper (pure, returns new array).computePageWindow(current, total)→['1', '...', '4', '5', '6', ..., '12'].renderPagination(totalCount, page)to render windowed nav, returns early iftotalPages <= 1.renderDeals()to:sortDeals(filtered, currentSort)after existing filter chain.currentPagewindow.#deals-countto "Showing X–Y of Z deals".animationDelayat in-page index (max ~480ms per page instead of scaling with total).renderPagination(sorted.length, currentPage).currentPage = 1and callupdatePageURL(1)beforerenderDeals().#pagination):pushState({ page }, '', url-with-page-param), setcurrentPage, callrenderDeals(), scroll to#deals(smooth or auto per reduced-motion).<select>change handler: setcurrentSort, resetcurrentPage = 1,updateSortURL(sort),renderDeals().pageandsort, apply tocurrentPage/currentSort, sync<select>value.window.addEventListener('popstate', ...)to restore page/sort from URL on back/forward.renderDeals,sortDeals,computePageWindow,renderPaginationonwindowfor test access (matches existingwindow.boot,window.activeCategoriespattern).if (window.__DEVcheap_DEBUG):[pagination] page ${prev} → ${next}, total ${totalCount}[sort] ${prev} → ${next}URL params restored → page=${page}, sort=${sort}[filters] cleared, reset to page 1.deal-card-overflow(Enter/Space) — this is a pre-existing WCAG gap surfaced during code review, fixed now while we're touchingsearch.js.css/style.css.deals-toolbar— flex,justify-content: space-between,align-items: baseline,flex-wrap: wrap,gap: 12px..sort-wrap,.sort-label,.sort-select— styled to match existing input tokens (--bg-surface,--border,--radius-md)..pagination— flex, centered,gap: 6px,margin-top: 28px,flex-wrap: wrap..page-btn—min-width: 36px,height: 36px,border-radius: var(--radius-md), matches.cat-btnstyling..page-btn[aria-current="page"]— primary accent background, white text..page-btn[disabled]—opacity: 0.4,cursor: not-allowed..page-ellipsis—padding: 0 4px,color: var(--text-tertiary),aria-hidden="true".@media (max-width: 640px)—.deals-toolbarstacks vertically,.paginationgap shrinks to 4px.@media (prefers-reduced-motion: reduce)— disablescardIn,pulse,copyPop,badge-glowanimations (also fixes the standalone a11y gap surfaced during code review).tests/ui-interactions.test.jsNew describe blocks:
Pagination controls:PAGE_SIZEPAGE_SIZEresultscurrentPageand re-rendersURLSearchParamsreflects current page after page click (pushState)popstateevent restorescurrentPagefrom URLaria-current="page"appears on exactly one button when paginatedaria-disabled="true"Sort select:expiresnearest-soonest first, no-expiry at endURLSearchParamsreflects sort paramtests/html-structure.test.js<select id="sort-select">with 4 expected<option>s (default/expiring/alphabetical/recommended).<label for="sort-select">exists.<nav id="pagination" aria-label="Deal pages">exists withhiddenattribute set in initial static HTML.tests/accessibility.test.jsaria-current="page"on current page button (when paginated).aria-disabled="true".<nav aria-label="Deal pages">exists.<select>has accessible name (label-for OR aria-label)..deal-card-overflowresponds to Enter/Space keyboard — covers the a11y fix bundled in this issue.Validation
Checklist for PR
prefers-reduced-motionblock disablescardIn/pulse/copyPop?page=N&sort=expiringdeep link boot-restores state?page=window.__DEVcheap_DEBUG = trueenables verbose console logsnpm run lint:cssandnpm run lint:htmlpass?page=/?sort=URL examples (no deal-count sync needed)Out of scope (follow-ups)
<noscript>fallback content for the gridadded/createdfield to JSONL → re-evaluaterecentsort