diff --git a/CLAUDE.md b/CLAUDE.md index e1d1e87..a47d03d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,6 +189,8 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN - **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete. - **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`. - **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls. +- **No styling-at-a-distance; elements carry their own classes**: `input.css` is document bootstrapping only (Tailwind import, theme, fonts, resets) — it contains **no form/component styling and no selectors that reach across the DOM** (`#id descendant`, `form input:disabled`, etc.) to style something a component owns. An element's appearance, **including state** (`disabled:`, `has-[:disabled]:`, `focus:`), comes from utility classes on that element, emitted by its component. This keeps state composable (no specificity wars) and robust to markup edits. +- **Forms render via `FormFields`/`AddForm`, never `form.as_div()`**: `FormFields(form, *, extras=...)` (in `primitives.py`) renders label + control + errors + row layout with their own classes; native controls get their classes from `PrimitiveWidgetsMixin` (`games/forms.py`, which stamps `INPUT/SELECT/TEXTAREA_CLASS` incl. `disabled:` variants by widget type, skipping SearchSelect + checkbox). Every form is on this path, including login (`LoginForm(PrimitiveWidgetsMixin, AuthenticationForm)`). `extras` appends a node into a named field's row (e.g. the session timestamp buttons). - **Disabling composite widgets**: a composite widget (e.g. `SearchSelect`) carries its `id` on a wrapper `
`, which has no `disabled` state — setting `.disabled` on it is a no-op. Disable the inner control (for `SearchSelect`, the `[data-search-select-search]` input); the **component owns its disabled *look*** via `has-[:disabled]:` utilities on its container class, so callers toggle only the control's `disabled`, never styles. - **Platform icons** are SVG snippets in `games/templates/icons/.html`. Add new ones there and reference them by slug in `Platform.icon`. - **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds. diff --git a/common/components/__init__.py b/common/components/__init__.py index 945a970..80c281d 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -53,6 +53,7 @@ from common.components.primitives import ( A, AddForm, ButtonGroup, + FormFields, Checkbox, CsrfInput, Div, @@ -114,6 +115,7 @@ __all__ = [ "randomid", "A", "AddForm", + "FormFields", "StyledButton", "ButtonGroup", "Checkbox", diff --git a/common/components/primitives.py b/common/components/primitives.py index 16a5dae..a4d3abf 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -600,6 +600,74 @@ def YearPicker( ) +# Form-field rendering. The element classes (label/error/checkbox-row + the +# controls, which carry their own classes via PrimitiveWidgetsMixin) live here, +# not in input.css — no selector reaches across the DOM to style a form. +_LABEL_CLASS = "mb-2.5 text-sm font-medium text-heading" +_FIELD_ERROR_CLASS = "mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px]" +# Checkbox + its label share a row (unlike block fields), justified apart. +_CHECKBOX_ROW_CLASS = "flex flex-row justify-between mt-3" + + +def _field_errors(errors) -> Node | None: + """Render a form/field ErrorList as a styled