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>
- 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>
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
- Wire the long-defined-but-unused _FIELD_CLASS into the container so pills and
the search input form a single padded flex row; the flex-1 input now fills the
widget instead of looking unclickable inside a larger box (affects both
SearchSelect and FilterSelect via the shared shell).
- Filter option labels get text-body so they're readable on dark backgrounds.
- Filter +/- buttons get text-body (readable at rest) and hover:border-brand-strong
so the border stays visible against the brand hover fill.
- Mirror the filter class changes in search_select.js and rebuild base.css.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
FilterSelect fully replaces it: delete SelectableFilter and its _selectable_*
helpers, the now-unused _get_filter_options, selectable_filter.js, and the .sf-*
rules in input.css (rebuilt base.css). The three list views load search_select.js
instead of selectable_filter.js. Drop the SelectableFilter export and refresh
docs/comments that referenced it.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
Replace the bespoke SelectableFilter in all three bars with FilterSelect: enum
fields (status, type, ownership) pre-render their fixed options; model-backed
fields (game(s), platform, device) use the search endpoints with prefetch and
resolve only the selected ids to pill labels — dropping the per-page queries that
fetched every game/platform/device. filter_bar.js now reads filter-mode
SearchSelect widgets via readSearchSelect (data-included/excluded/modifier),
preserving the {value, excludes, modifier} JSON and id Number() coercion; the
redundant session game/device blocks are gone. Drop FilterBar's now-unused
platform_options param. Rebuild base.css for the inline filter-pill utilities and
update the bar tests to the new markup.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS