Take Basecoat's form layer: one component draws 117 rows, and the framework already has the rest #290

Closed
opened 2026-09-19 17:40:43 +00:00 by tiagoagueda · 0 comments
Owner

#262 brought Basecoat in one component at a time and adopted four: button, popover, dropdown-menu and switch. The framework ships thirty-eight, and twenty-four of them need no JavaScript at all — which answers the only objection recorded against the rest. scripts/sync-vendor.mjs says Basecoat's scripts are not vendored because a menu driven by script has no path with scripts off; the form components are not those. field, label, input, textarea, native-select, checkbox, radio and input-group are stylesheet and nothing else.

This issue is the form layer. It is the largest single surface in the application and the one where adoption costs least, because #263 already put every form row behind one component.

What Postulo draws today

Four classes in assets/css/app.css, written before there was a framework to take them from:

Class Where
field-input assets/css/app.css:319 — the box, the border, the focus ring, aria-invalid
field-label assets/css/app.css:476
field-help assets/css/app.css:480
field-error assets/css/app.css:484

They are applied in two ways, and the ratio is the argument for doing this now:

  • 117 rows go through <c-field> (templates/cotton/field.html), which draws label, widget, help and errors in one place. Changing what a form row is means changing that one file.
  • 58 more write field-input by hand, across 29 templates — the rows a single field cannot draw: the identifier rows, the telephone widget, the postal address rows, the formsets, and the settings pages with custom layouts.

What Basecoat's form components are, and why they fit this shape exactly

They are element-scoped inside a wrapper, not a class per control:

.field > label, .field > section > label, .label { … }
.field > input[type='text'], .field > input[type='email'], … , .input[type='text'] { … }
.field[data-orientation='horizontal'] { … }
.field[data-invalid] { … }

So a form row is <div class="field"> with an ordinary <label> and an ordinary <input> inside it, and nothing carries a class at all. That is what <c-field> already emits the shape of. The swap is one component's innards plus the paint in assets/css/basecoat.css, and 117 rows change at once.

It also brings what Postulo has never had:

  • data-orientation="horizontal" — a label beside its control rather than above it, which is what the checkbox rows on the settings pages hand-build with flex items-center gap-3 today.
  • [data-invalid] on the field, so an error styles the whole row rather than only the message. Postulo marks aria-invalid on the input alone.
  • input-group (74 lines, no script) — a control with something attached to it. Postulo hand-builds exactly this three times: partials/phone_widget.html:21-34, partials/postal_addresses.html:57 and server/defaults.html:27, each a relative wrapper with absolute inset-y-0 start-2.5 over a select padded with ps-9, to put a flag over a chooser. One component replaces all three, and the next field that wants a prefix or a unit gets it for nothing.
  • native-select — Postulo styles <select> with field-input, which is an input's box on a control that is not one.

The order to do it in

Each import is a deliberate act, as #262 established, and tests/test_stylesheet.py refuses an import whose classes app.css also defines. None of these collide with a name Postulo owns — app.css defines field-input, field-label, field-help, field-error, not a bare field, label or input — so this family can arrive without a rename, which is not true of card or alert.

  1. label, input, textarea, native-select, field — and <c-field> emits the structure. The four field-* classes stay for one commit so the hand-written rows keep working.
  2. checkbox and radio, which the settings and plugin pages draw by hand.
  3. input-group, and the three flag fields become one component.
  4. The 58 hand-written rows move to components — <c-field-row> or the shape each wants — and the four field-* classes are deleted. tests/test_template_lint.py gains a rule refusing them, as it did for the retired button classes.

What must not change

The floor is tested and none of this is allowed to lower it:

  • tests/test_contrast.py holds the field border at 3:1 against the page and the field in both themes, and the focus ring opaque (#274). Basecoat's border is border-input, which is already Postulo's token, so the paint decides this and the test keeps deciding it.
  • Forced colours: app.css opts the field's focus outline back in, because a box-shadow ring is discarded (#227, #277). Basecoat uses a ring too, so that block has to cover the new selectors.
  • Target size 24×24 (tests/e2e/test_target_size.py), reflow at 320 in three languages, the text-spacing override and 200% zoom (tests/e2e/test_text_spacing.py). A denser control is exactly what those catch.
  • Every control works with scripts off. These components have no script, so this is free — but the tests are the proof, not the claim.

Done when

  • <c-field> emits Basecoat's field structure, the four field-* classes are gone from app.css and from every template, and test_template_lint.py refuses them.
  • The three flag fields are one input-group component.
  • assets/css/basecoat.css paints the new families, and test_stylesheet.py's ownership guard still passes.
  • Every browser check passes unchanged, and test_contrast.py holds the same ratios against the new selectors.
#262 brought Basecoat in one component at a time and adopted four: button, popover, dropdown-menu and switch. The framework ships **thirty-eight**, and **twenty-four of them need no JavaScript at all** — which answers the only objection recorded against the rest. `scripts/sync-vendor.mjs` says Basecoat's scripts are not vendored because a menu driven by script has no path with scripts off; the form components are not those. `field`, `label`, `input`, `textarea`, `native-select`, `checkbox`, `radio` and `input-group` are stylesheet and nothing else. This issue is the form layer. It is the largest single surface in the application and the one where adoption costs least, because #263 already put every form row behind one component. ## What Postulo draws today Four classes in `assets/css/app.css`, written before there was a framework to take them from: | Class | Where | | --- | --- | | `field-input` | `assets/css/app.css:319` — the box, the border, the focus ring, `aria-invalid` | | `field-label` | `assets/css/app.css:476` | | `field-help` | `assets/css/app.css:480` | | `field-error` | `assets/css/app.css:484` | They are applied in two ways, and the ratio is the argument for doing this now: - **117 rows go through `<c-field>`** (`templates/cotton/field.html`), which draws label, widget, help and errors in one place. Changing what a form row *is* means changing that one file. - **58 more write `field-input` by hand, across 29 templates** — the rows a single field cannot draw: the identifier rows, the telephone widget, the postal address rows, the formsets, and the settings pages with custom layouts. ## What Basecoat's form components are, and why they fit this shape exactly They are **element-scoped inside a wrapper**, not a class per control: ```css .field > label, .field > section > label, .label { … } .field > input[type='text'], .field > input[type='email'], … , .input[type='text'] { … } .field[data-orientation='horizontal'] { … } .field[data-invalid] { … } ``` So a form row is `<div class="field">` with an ordinary `<label>` and an ordinary `<input>` inside it, and nothing carries a class at all. That is what `<c-field>` already emits the shape of. **The swap is one component's innards plus the paint in `assets/css/basecoat.css`, and 117 rows change at once.** It also brings what Postulo has never had: - **`data-orientation="horizontal"`** — a label beside its control rather than above it, which is what the checkbox rows on the settings pages hand-build with `flex items-center gap-3` today. - **`[data-invalid]`** on the field, so an error styles the whole row rather than only the message. Postulo marks `aria-invalid` on the input alone. - **`input-group`** (74 lines, no script) — a control with something attached to it. Postulo hand-builds exactly this **three times**: `partials/phone_widget.html:21-34`, `partials/postal_addresses.html:57` and `server/defaults.html:27`, each a `relative` wrapper with `absolute inset-y-0 start-2.5` over a select padded with `ps-9`, to put a flag over a chooser. One component replaces all three, and the next field that wants a prefix or a unit gets it for nothing. - **`native-select`** — Postulo styles `<select>` with `field-input`, which is an input's box on a control that is not one. ## The order to do it in Each import is a deliberate act, as #262 established, and `tests/test_stylesheet.py` refuses an import whose classes `app.css` also defines. None of these collide with a name Postulo owns — `app.css` defines `field-input`, `field-label`, `field-help`, `field-error`, not a bare `field`, `label` or `input` — so this family can arrive without a rename, which is not true of `card` or `alert`. 1. `label`, `input`, `textarea`, `native-select`, `field` — and `<c-field>` emits the structure. The four `field-*` classes stay for one commit so the hand-written rows keep working. 2. `checkbox` and `radio`, which the settings and plugin pages draw by hand. 3. `input-group`, and the three flag fields become one component. 4. The 58 hand-written rows move to components — `<c-field-row>` or the shape each wants — and the four `field-*` classes are deleted. `tests/test_template_lint.py` gains a rule refusing them, as it did for the retired button classes. ## What must not change The floor is tested and none of this is allowed to lower it: - `tests/test_contrast.py` holds the field border at 3:1 against the page and the field in both themes, and the focus ring opaque (#274). Basecoat's border is `border-input`, which is already Postulo's token, so the paint decides this and the test keeps deciding it. - Forced colours: `app.css` opts the field's focus outline back in, because a box-shadow ring is discarded (#227, #277). Basecoat uses a ring too, so that block has to cover the new selectors. - Target size 24×24 (`tests/e2e/test_target_size.py`), reflow at 320 in three languages, the text-spacing override and 200% zoom (`tests/e2e/test_text_spacing.py`). A denser control is exactly what those catch. - Every control works with scripts off. These components have no script, so this is free — but the *tests* are the proof, not the claim. ## Done when - `<c-field>` emits Basecoat's field structure, the four `field-*` classes are gone from `app.css` and from every template, and `test_template_lint.py` refuses them. - The three flag fields are one `input-group` component. - `assets/css/basecoat.css` paints the new families, and `test_stylesheet.py`'s ownership guard still passes. - Every browser check passes unchanged, and `test_contrast.py` holds the same ratios against the new selectors.
tiagoagueda added this to the 0.5.0 milestone 2026-09-19 17:40:43 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#290
No description provided.