Own all form styling in components; remove form CSS from input.css

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>
This commit is contained in:
2026-06-20 07:31:53 +02:00
parent b13cc3c324
commit 02798f8858
14 changed files with 217 additions and 257 deletions
+17 -1
View File
@@ -128,6 +128,21 @@ class RenderedPagesTest(TestCase):
self.assertIn("dist/add_game.js", html)
self.assertIn("submit_and_redirect", html)
self.assertIn("Submit &amp; Create Purchase", html) # & correctly escaped
# Fields self-style: label + control carry their own classes (no #add-form
# / form CSS in input.css).
self.assertIn("mb-2.5 text-sm font-medium text-heading", html) # _LABEL_CLASS
self.assertIn("bg-neutral-secondary-medium", html) # INPUT_CLASS surface
self.assertNoEscapedTags(html)
def test_form_errors_render_with_component_class(self):
"""Invalid submits re-render field errors via FormFields' own class, not
Django's .errorlist (which no longer exists in the CSS)."""
# Non-empty but invalid (name is required) so the form binds and
# re-renders with errors — an empty {} POST is falsy and stays unbound.
response = self.client.post(reverse("games:add_game"), {"status": "u"})
html = response.content.decode()
self.assertIn("bg-red-600", html) # _FIELD_ERROR_CLASS
self.assertNotIn('class="errorlist"', html)
self.assertNoEscapedTags(html)
def test_add_purchase_form(self):
@@ -264,8 +279,9 @@ class RenderedPagesTest(TestCase):
"<!DOCTYPE html>", # full Page() layout
"Please log in to continue",
"csrfmiddlewaretoken",
'name="username"', # auth form fields rendered via FormFields
'type="submit"',
'value="Login"',
">Login<", # StyledButton submit (was an <input value="Login">)
"</html>",
]:
self.assertIn(marker, html)