base.css is a Tailwind build artifact already listed in .gitignore, but it
had been tracked since it was first committed (gitignore can't untrack an
already-tracked file), so it kept getting re-committed against intent. Untrack
it to match js/dist (the TS artifact, also gitignored + untracked).
Because nothing in the test path rebuilt it, e2e/static serving relied on the
committed copy. Add 'css' (and 'ts') as prerequisites of the test/test-e2e
make targets and a Build CSS step in CI so the stylesheet is generated before
tests run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flowbite re-initialises popovers on every htmx swap. A popover hidden via
Tailwind `invisible` (visibility:hidden) still occupies layout, so once
Popper parks it with a transform offset it expands the table's
overflow-x-auto wrapper and a spurious scrollbar appears (horizontal here,
vertical in #40). Add `[&.invisible]:hidden` so the popover is removed from
layout while hidden; Flowbite drops `invisible` on show, restoring display.
Relates to #40. e2e regression covers no-overflow-after-swap plus
popover-still-shows-on-hover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SearchSelect wrapper had no border and no focus styling, so it looked unlike
a native input (which has border-default-medium → border-brand on focus). Add
border-default-medium + focus-within:border-brand/ring to the shared container
class — focus-within because the focusable element is the inner search box. This
also makes filter-bar comboboxes consistent with the other filter inputs, which
already have borders.
e2e asserts the wrapper border matches a native input's at rest and turns brand
on focus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Disabled controls looked inconsistent: the SearchSelect faded (opacity-50)
while native inputs used a solid strong surface. Standardize on the faded look
(opacity-50) the user preferred, via shared constants so every form element
matches:
- DISABLED_CONTROL_CLASS (disabled:opacity-50 disabled:cursor-not-allowed) on
the control — native inputs/select/textarea via PrimitiveWidgetsMixin, plus
the Checkbox component (previously had no disabled style).
- DISABLED_WITHIN_CLASS (has-[:disabled]: wrapper variant) for composite
controls like SearchSelect whose disabled state lives on an inner element.
e2e asserts a disabled SearchSelect and the Name input fade identically
(opacity 0.5) and return to 1 when enabled. CLAUDE.md documents the shared
disabled constants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Form controls were styled "at a distance": Django renders bare
<input>/<select>/<textarea>/<label>, so input.css reached in with ID-scoped
#add-form descendant rules plus a global form *:disabled rule and .errorlist.
The #add-form ID specificity forced state rules to climb, needed
:not([data-search-select-search]) carve-outs, and broke on markup changes — it
surfaced as the add_purchase Name/related_game fields not reading as disabled.
Components now own all form styling via utilities on the elements themselves:
- PrimitiveWidgetsMixin stamps INPUT/SELECT/TEXTAREA_CLASS (incl. disabled:
variants) onto native widgets by type, skipping SearchSelect (self-styled)
and checkboxes.
- New FormFields(form, *, extras=...) renders label + control + errors + row
layout with their own classes (replaces form.as_div()); the <form> owns its
flex layout. extras appends a node into a named field's row (session
timestamp buttons).
- AddForm/purchase/session render via FormFields; login too — a new
LoginForm(PrimitiveWidgetsMixin, AuthenticationForm) styles its inputs and
auth.py renders it via FormFields + a StyledButton (was as_table).
- input.css loses the entire #add-form block, the global :disabled rule, and
.errorlist. State (disabled:) now lives on the element — no specificity wars,
no carve-outs, robust to markup edits.
Tests: error rendering uses the component class (not .errorlist); add-form
labels/inputs carry their own classes; e2e login fixtures click the Login
button by text (submit is now a <button>); Name disabled cursor asserted.
CLAUDE.md documents the no-styling-at-a-distance + FormFields conventions.
513 passed; lint/format/ts-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Excluding the inner search box from the global disabled rule also dropped its
cursor: not-allowed, so the pointer flickered between not-allowed (wrapper) and
the text I-beam (input) when moving across the disabled widget. Add
disabled:cursor-not-allowed to the search input so the cursor stays consistent.
e2e: assert the disabled inner input computes cursor: not-allowed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The disabled widget showed two clashing surfaces in dark mode: the wrapper
(faded via has-[:disabled]) plus the inner search input, which picked up the
global disabled-input fill from common/input.css
(`form input:disabled { background: neutral-secondary-strong }`). That rule is
unlayered, so it beat any utility override on the input.
Exclude the SearchSelect's inner search box from that global rule
(`:not([data-search-select-search])`) so it stays transparent — the wrapper is
then the single faded surface. Standalone inputs (e.g. the Name field) keep
their distinct disabled surface, unchanged.
e2e: assert the disabled inner input computes transparent background (one
element), alongside the existing wrapper-opacity check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups on the add-form fixes:
- syncSelectInputUntilChanged now actually stops mirroring once the user edits
the target (the "UntilChanged" contract). The old focus-based stop was a
no-op (wrong removeEventListener reference), so live sync kept clobbering a
manually-edited Sort name. Track dirty targets in a Set keyed by syncData
index; programmatic writes don't fire "input", so only real user edits mark a
target dirty. Drops the dead focus listener.
- SearchSelect now greys itself when disabled, via has-[:disabled]: utilities on
its container class — the visible "box" is the wrapper <div>, so disabling the
transparent inner input alone left it looking active. The component owns its
disabled appearance; callers only toggle the inner control's `disabled`.
- Document the composite-widget disabling approach in CLAUDE.md and the
SearchSelect docstring.
Extends the e2e tests: sync drops after a manual Sort name edit; disabled
related-game wrapper computes opacity 0.5 (and 1 when re-enabled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add ts/htmx-redirect-toast.ts: typed port of the hx-redirect-toast htmx
extension. Stays a classic (non-module) script — only touches the global
htmx and registers an extension; layout.py now serves dist/htmx-redirect-toast.js
- Delete games/static/js/utils.js: the legacy hand-written copy is dead — every
compiled module imports dist/utils.js (from ts/utils.ts); nothing references
the old path
With this, the only first-party JS served is compiled from ts/; the sole
remaining hand-written .js in static is the vendored datepicker.umd.js bundle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/year_picker.ts: typed onSwap port of the year-picker glue. Datepicker
declared as an ambient global (vendored UMD); PickerElement types the
_pickerInstance prop the Alpine toggle button reaches
- Remove the duplicate inline <script> from the YearPicker component (was a JS
blob in a Python f-string — the CLAUDE.md anti-pattern) and the orphaned
games/static/js/year_picker.js that nothing loaded; the component now declares
dist/year_picker.js as media alongside the datepicker UMD bundle
- Module defer semantics keep the classic UMD bundle running before the
deferred year_picker module, so Datepicker is defined in time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/toast.ts: typed port of the Alpine toast store + window.toast +
window.fetchWithHtmxTriggers. Toast / ToastStore / ToastMessage interfaces
type the store and the show-toast CustomEvent detail; Alpine declared as a
type-only ambient global
- Declare window.toast in ts/globals.d.ts
- Stays a classic (non-module) script — no import/export — so it keeps defining
globals; layout.py now serves dist/toast.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/date_range_picker.ts: typed port. CalendarState interface (with the
dynamically-assigned refreshFromField) and an Anchor union replace the loose
state object; date helpers and DOM queries fully typed; var → const/let
- Replace the DOMContentLoaded + per-element guard-flag + window global with
onSwap("[data-date-range-picker]", ...), the documented init pattern — so the
picker now also initializes inside htmx-swapped fragments. Drops the dead
window.initDateRangePickers export
- Point the DateRangePicker component Media at dist/date_range_picker.js and load
it as an ES module in the e2e page (was a deferred classic script)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/range_slider.ts: typed port of the custom range-slider widget. Number
inputs typed as HTMLInputElement; setTargetValue coerces via String(); mouse
handlers typed MouseEvent; var → const/let
- Point the RangeSlider component Media and every e2e/test reference at the
compiled dist/range_slider.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/filter_bar.ts: typed port of the filter bar. Criterion / PillEntry /
RangeField / DeselectableRadio interfaces replace the loose objects and the
radio.wasChecked custom property; var → const/let throughout
- Window entry points (applyFilterBar/clearFilterBar/toggleStringFilterInput/
showPresetNameInput/savePreset) declared in ts/globals.d.ts; readSearchSelect
now called as window.readSearchSelect
- Drop the dead selectValue helper; factor the repeated path→mode mapping into
presetMode()
- Point the FilterBar component Media and every e2e/test reference at the
compiled dist/filter_bar.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/search_select.ts: typed port of the SearchSelect/FilterSelect widget.
Exports SearchSelectOption / SearchSelectChangeDetail as the single source of
truth for the "search-select:change" event contract
- add_purchase.ts now imports those types via `import type` (no runtime
coupling), instead of redefining them locally
- Declare window.readSearchSelect in ts/globals.d.ts
- Point the SearchSelect component Media and every view/e2e/test reference at
the compiled dist/search_select.js
- Update doc comments in common/components/search_select.py to name the TS source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/add_purchase.ts: typed port of add_purchase.js. Replaces getEl with
document.querySelector; types the search-select:change CustomEvent detail
(SearchSelectChangeDetail / SearchSelectOption)
- Point add_purchase / edit_purchase views at compiled dist/add_purchase.js
- Delete add_edition.js: no Edition model/view/url/template references it
(feature was removed; the script was dead)
- Delete the now-superseded add_game.js / add_purchase.js source files
- Tighten test_rendered_pages assertions to the dist/ script paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A multi-game Purchase is now treated as an *unsplittable* bundle (one
price, whole-purchase refund). Independently-refundable multi-item orders
(e.g. a Steam cart) are instead recorded as N separate single-game
purchases, so per-game pricing and per-game refunds work with the
existing single-purchase machinery — no through-model needed.
Add-purchase form (single form, single endpoint):
- 1 game: unchanged.
- 2+ games: a "Separate price per game" toggle appears (default off =
one bundle price). On, the bundle Price hides and one price input per
game appears; the view creates one single-game Purchase each from
price_for_game_<id>. `price` is now optional so combined mode still
validates.
Split action:
- A Split button on multi-game purchase rows opens a confirmation modal
that replaces the bundle with one single-game purchase per game (price
split evenly, needs_price_update set), then HX-Redirects to the list.
New general-purpose `selection-fields` custom element renders one synced
form field per selected item of a source SearchSelect (consuming the
existing search-select:change contract); it knows nothing about prices,
so it is reusable. Behavior in ts/elements/selection-fields.ts.
Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split
icon, and unit + Playwright e2e coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add-on purchases (DLC, Season Pass, Battle Pass) previously linked to a
parent *purchase* via the `related_purchase` self-FK. When the base game
was bought inside a multi-game purchase (e.g. a bundle), there was no
per-game purchase to point at — only the whole bundle.
Replace it with a `related_game` FK (Game -> Game): an add-on belongs to
a *game*, which is unambiguous regardless of how the base game was bought.
- models: drop `related_purchase`; add `related_game`
(SET_NULL, related_name="addon_purchases"); require it for non-GAME
types in `save()`.
- forms: replace the parent-purchase picker with a flat `related_game`
game search (reusing SearchSelectWidget/_game_options); drop the now
unused related_purchase_queryset/RelatedPurchaseChoiceField.
- views/urls: remove the obsolete related_purchase_by_game endpoint.
- add_purchase.js: drop the parent-dropdown refetch; keep platform
auto-fill; retarget the type toggle to #id_related_game.
- migration 0020: add -> backfill (related_game = parent's first game by
sort_name) -> remove related_purchase.
- tests: model validation unit tests + an e2e test for the flat picker.
related_game is deliberately game->game so it can later be synced from
IGDB's parent_game without schema changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Selector menu options were bare <button>s with no padding, so the open
dropdown items were cramped. Add a shared option class (block w-full
text-left px-4 py-2 + hover), matching the original <a> list items.
- The played-row's relative menu wrapper was a block div, so in the inline-flex
button group the chevron toggle sat lower than the count button. Make the
wrapper inline-flex and the group items-stretch so the two buttons align into
one rounded group again.
- Rebuild base.css for the newly-used utilities.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port FastHTML's proc_htmx as onSwap(selector, initializeElement) in
utils.js, built on htmx.onLoad: it runs an initializer once per matching
element, on initial page load and inside every htmx-swapped fragment.
Migrate search_select.js, range_slider.js, filter_bar.js and
add_purchase.js to it, removing the hand-rolled DOMContentLoaded +
htmx:afterSwap listeners and per-element guard flags. This also fixes a
latent bug: both events passed the Event object as range_slider's
"force" parameter, so every htmx swap force-re-initialized all sliders
and stacked duplicate listeners. The collapse button's
window.initRangeSliders() call was a no-op (handles are positioned in
percentages, so hidden-init is safe) and is removed with the global.
Add e2e/test_widgets_e2e.py covering the onSwap lifecycle (initial-load
init, htmx-swap init, single-fire toggles) plus FilterSelect pills and
the add-purchase type toggle. The synthetic page in
test_search_select_e2e.py now loads htmx and search_select.js as a
module, matching the new initialization path.
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
Serve alpinejs 3.15.12, @alpinejs/mask 3.15.12, flowbite 2.4.1 and
flowbite-datepicker 2.0.0 from games/static/js/ instead of jsdelivr, so
pages (and browser tests) work without network access. Adds the
StaticScript primitive for vendored UMD bundles, which cannot be loaded
as ES modules.
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
Implements the DateRangePicker design: a DateRangeField that looks like a
single input but splits each date into DD/MM/YYYY part inputs (ordered by
the new common.time.dateformat_hyphenated), and a DateRangeCalendar popup
with a preset column (today, yesterday, last 7/30 days, this/last month,
this year), anchor-style range picking with an outlined/filled/muted range
track, and a Cancel / Clear / Select footer.
Typing fills each part's placeholder from the right (YYYY -> YY19 -> 1987),
auto-advances between parts, and Backspace/Delete reverts the active part.
The committed value lives in hidden ISO {prefix}-min/{prefix}-max inputs --
the same contract as DateRangeFilter, so filter_bar.js needs no changes.
As a tryout, the Purchased filter in PurchaseFilterBar now uses the
DateRangePicker; Refunded keeps the native-date DateRangeFilter, and the
native-path e2e tests were repointed at it.
Includes unit tests for the component family and the filter-bar
integration, plus Playwright e2e tests for segment entry, calendar
picking, presets, and footer actions.
https://claude.ai/code/session_017b75KJAu4kNNpZPu9NAPBM
Closes#10.
Backend (common/criteria.py):
- Treat `excludes` as an always-orthogonal AND'd negative across both
MultiCriterion and ChoiceCriterion; the modifier now governs only the
`value` (include) set. This removes the prior divergence where
MultiCriterion.EXCLUDES dropped the excludes list and ChoiceCriterion.EXCLUDES
swapped include/exclude into a positive.
- Fold INCLUDES / INCLUDES_ALL / EXCLUDES (+ EQUALS/NOT_EQUALS aliases) into the
shared _SetCriterion base so the two subclasses cannot drift; remove _extra_q.
M2M "has all" (games/filters.py):
- PurchaseFilter._games_to_q builds a pk__in subquery with one join per value so
INCLUDES_ALL on the many-to-many games field works in a single .filter()
(a naive Q(games=a) & Q(games=b) collapses to one join and matches nothing).
UI (FilterSelect + filter_bar.js):
- Add an optional any/all/none match-mode <select> (INCLUDES/INCLUDES_ALL/
EXCLUDES) rendered before the pills via a new `leading` slot on the shared
combobox shell. A native control so its value is its state. readSearchSelect
serialises it to data-match; filter_bar folds it into the criterion modifier.
Orthogonal to the (Any)/(None) presence pseudo-options and the exclude channel.
- Enable it for the M2M Purchase.games field (INCLUDES_ALL is only meaningful
for multi-valued relations). Styled with already-compiled utilities.
Tests: harmonized EXCLUDES + INCLUDES_ALL for both criterion types, a DB-backed
INCLUDES_ALL vs INCLUDES contrast on Purchase.games, and FilterSelect /
PurchaseFilterBar rendering + round-trip of the match mode.
https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
- Move {id,label} stripping into _SetCriterion.from_json() so both
MultiCriterion and ChoiceCriterion normalise at the parse boundary;
the querying layer stays typed (list[int] / list[str]) and clean.
- Revert MultiCriterion to a thin _extra_q() override; _SetCriterion.to_q()
is no longer duplicated.
- JS: readSearchSelect always emits {id, label} objects — no conditional
mixed-type arrays. filter_bar.js stores them as-is for all fields,
removing the fragile isIdField hardcoded list.
- Update tests to use the {id, label} filter format.
https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
Store {id, label} objects instead of bare IDs in MultiCriterion value/excludes.
FilterSelect pills now render directly from the embedded labels — no DB round-trip
to _resolve_game/device/platform_options. The filter URL and saved presets are
self-describing. MultiCriterion.to_q() extracts ids for querying; bare ints are
still accepted for backward compatibility.
Closes#9https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA