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
+44
View File
@@ -1,4 +1,5 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.db import transaction
from common.components import (
@@ -25,6 +26,30 @@ custom_datetime_widget = forms.DateTimeInput(
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
# Form controls self-style: these utility strings live on the elements (applied
# by PrimitiveWidgetsMixin), so there is no form styling in input.css and no
# selector reaching in to style them. The disabled: variants put state on the
# element too — no specificity wars, robust to markup changes.
_DISABLED_CONTROL = (
"disabled:bg-neutral-secondary-strong disabled:text-fg-disabled "
"disabled:cursor-not-allowed"
)
INPUT_CLASS = (
"mb-3 bg-neutral-secondary-medium border border-default-medium text-heading "
"text-sm rounded-base focus:ring-brand focus:border-brand block w-full "
f"px-3 py-2.5 shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
SELECT_CLASS = (
"w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium "
"text-heading text-sm rounded-base focus:ring-brand focus:border-brand "
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
TEXTAREA_CLASS = (
"bg-neutral-secondary-medium border border-default-medium text-heading "
"text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 "
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
)
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
@@ -60,6 +85,20 @@ class PrimitiveWidgetsMixin:
if isinstance(field, forms.BooleanField):
field.widget = PrimitiveCheckboxWidget()
# Maintain the field's explicit required status (usually False for booleans)
continue
widget = field.widget
# SearchSelect is a self-styled composite component; never stamp the
# native-control classes onto it.
if isinstance(widget, SearchSelectWidget):
continue
if isinstance(widget, forms.Select):
control_class = SELECT_CLASS
elif isinstance(widget, forms.Textarea):
control_class = TEXTAREA_CLASS
else:
control_class = INPUT_CLASS
existing = widget.attrs.get("class", "")
widget.attrs["class"] = f"{existing} {control_class}".strip()
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
@@ -420,3 +459,8 @@ class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
widgets = {
"timestamp": custom_datetime_widget,
}
class LoginForm(PrimitiveWidgetsMixin, AuthenticationForm):
"""Django's auth form with our primitive widget styling so login inputs
self-style like every other form (no styling-at-a-distance)."""
+13 -132
View File
@@ -2369,6 +2369,9 @@
.p-3 {
padding: calc(var(--spacing) * 3);
}
.p-3\.5 {
padding: calc(var(--spacing) * 3.5);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
@@ -3435,6 +3438,16 @@
cursor: not-allowed;
}
}
.disabled\:bg-neutral-secondary-strong {
&:disabled {
background-color: var(--color-neutral-secondary-strong);
}
}
.disabled\:text-fg-disabled {
&:disabled {
color: var(--color-fg-disabled);
}
}
.has-\[\:disabled\]\:cursor-not-allowed {
&:has(*:is(:disabled)) {
cursor: not-allowed;
@@ -4405,20 +4418,6 @@
border-left-color: var(--color-slate-500);
}
}
form input:disabled:not([data-search-select-search]), select:disabled, textarea:disabled {
cursor: not-allowed;
background-color: var(--color-neutral-secondary-strong);
color: var(--color-fg-disabled);
}
.errorlist {
margin-top: calc(var(--spacing) * 4);
margin-bottom: calc(var(--spacing) * 1);
width: 300px;
background-color: var(--color-red-600);
padding-block: calc(var(--spacing) * 2);
padding-left: calc(var(--spacing) * 3);
color: var(--color-slate-200);
}
#button-container button {
margin-inline: calc(var(--spacing) * 1);
}
@@ -4512,124 +4511,6 @@ form input:disabled:not([data-search-select-search]), select:disabled, textarea:
margin-bottom: 0.5em;
padding-left: 1em;
}
#add-form {
label + select, input, textarea {
margin-top: calc(var(--spacing) * 1);
}
form {
display: flex;
flex-direction: column;
gap: calc(var(--spacing) * 3);
}
.form-row-button-group {
display: flex;
flex-direction: row;
gap: calc(var(--spacing) * 0);
padding: calc(var(--spacing) * 0);
button {
margin-right: calc(var(--spacing) * 0);
&:first-child {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&:nth-child(2) {
border-radius: 0;
}
&:last-child {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
}
}
label {
margin-bottom: calc(var(--spacing) * 2.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
color: var(--color-heading);
}
input:not([type="checkbox"]):not([data-search-select-search]) {
margin-bottom: calc(var(--spacing) * 3);
display: block;
width: 100%;
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 2.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
&::placeholder {
color: var(--color-body);
}
&:focus {
border-color: var(--color-brand);
}
&:focus {
--tw-ring-color: var(--color-brand);
}
}
select {
width: 100%;
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 2.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
&::placeholder {
color: var(--color-body);
}
&:focus {
border-color: var(--color-brand);
}
&:focus {
--tw-ring-color: var(--color-brand);
}
}
textarea {
display: block;
width: 100%;
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
padding: calc(var(--spacing) * 3.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
&::placeholder {
color: var(--color-body);
}
&:focus {
border-color: var(--color-brand);
}
&:focus {
--tw-ring-color: var(--color-brand);
}
}
:has(> label + input[type="checkbox"]) {
margin-top: calc(var(--spacing) * 3);
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
position: fixed;
+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(
+2 -1
View File
@@ -180,7 +180,8 @@ def add_game(request: HttpRequest) -> HttpResponse:
),
),
title="Add New Game",
scripts=ModuleScript("dist/search_select.js") + ModuleScript("dist/add_game.js"),
scripts=ModuleScript("dist/search_select.js")
+ ModuleScript("dist/add_game.js"),
)
+2 -2
View File
@@ -22,6 +22,7 @@ from common.components import (
CsrfInput,
Div,
Element,
FormFields,
Fragment,
GameLink,
Icon,
@@ -32,7 +33,6 @@ from common.components import (
Node,
PriceConverted,
PurchasePrice,
Safe,
SelectionFields,
StyledButton,
TableRow,
@@ -296,7 +296,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
AddForm(
form,
request=request,
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
fields=Fragment(FormFields(form), _pricing_controls()),
additional_row=_purchase_additional_row(),
),
title="Add New Purchase",
+28 -33
View File
@@ -15,13 +15,13 @@ from common.components import (
AddForm,
ButtonGroup,
Div,
FormFields,
Fragment,
Icon,
ModuleScript,
NameWithIcon,
Node,
Popover,
Safe,
SearchField,
SessionDeviceSelector,
SessionTimestampButtons,
@@ -193,39 +193,34 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
def _session_fields(form) -> Fragment:
"""Manual per-field layout for the session form.
def _timestamp_buttons(field_name: str) -> Node:
"""The now/toggle/copy helper buttons appended to a timestamp field's row."""
this_side = "start" if field_name == "timestamp_start" else "end"
other_side = "end" if field_name == "timestamp_start" else "start"
return SessionTimestampButtons(
class_="flex flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field_name, data_type="now", size="xs")["Set to now"],
StyledButton(data_target=field_name, data_type="toggle", size="xs")[
"Toggle text"
],
StyledButton(data_target=field_name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
Mirrors the old add_session.html: each field gets its label and widget,
and the timestamp fields gain a row of now/toggle/copy helper buttons.
"""
rows: list[Node] = []
for field in form:
children: list[Node | str] = [
Safe(str(field.label_tag())),
Safe(str(field)),
]
if field.name in ("timestamp_start", "timestamp_end"):
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
SessionTimestampButtons(
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field.name, data_type="now", size="xs")[
"Set to now"
],
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
"Toggle text"
],
StyledButton(data_target=field.name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
def _session_fields(form) -> Node:
"""Session form fields via the shared renderer, with timestamp helper
buttons appended to the two timestamp rows."""
return FormFields(
form,
extras={
name: _timestamp_buttons(name)
for name in ("timestamp_start", "timestamp_end")
},
)
@login_required