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
+15 -22
View File
@@ -4,29 +4,12 @@ registration/login.html)."""
from django.contrib.auth import views as auth_views
from django.http import HttpResponse
from common.components import CsrfInput, Div, Element, Input, Node, Safe
from common.components.primitives import Td, Tr
from common.components import CsrfInput, Div, Element, FormFields, Node, StyledButton
from common.layout import render_page
from games.forms import LoginForm
def _login_content(form, request) -> Node:
table = Element(
"table",
children=[
CsrfInput(request),
Safe(str(form.as_table())),
Tr(
children=[
Td(),
Td(
children=[
Input(type="submit", attributes=[("value", "Login")])
],
),
],
),
],
)
return Div(
[("class", "flex items-center flex-col")],
[
@@ -37,15 +20,25 @@ def _login_content(form, request) -> Node:
),
Element(
"form",
attributes=[("method", "post")],
children=[table],
attributes=[
("method", "post"),
("class", "flex flex-col gap-3 w-full max-w-sm"),
],
children=[
CsrfInput(request),
FormFields(form),
StyledButton([], "Login", type="submit"),
],
),
],
)
class LoginView(auth_views.LoginView):
"""Django's LoginView, but the page body is built in Python."""
"""Django's LoginView, but the page body is built in Python and the form is
our `LoginForm` so its inputs self-style like every other form."""
authentication_form = LoginForm
def render_to_response(self, context, **response_kwargs) -> HttpResponse:
return render_page(