Adopt Basecoat for the component layer, while most of the interface is still unwritten #262
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Postulo/postulo#262
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Postulo's component layer is hand-written and growing:
@layer componentsinassets/css/app.cssis 819 lines and about thirty classes —btn,card,chip,tag,field-input,alert-*,menu-item,state-glow,tap-target. Every one was writtenhere, and every new screen either reuses one or adds another.
Basecoat is shadcn/ui's design system rebuilt as plain
Tailwind CSS and vanilla JavaScript — no React, no Radix, no framework runtime. It ships
about forty-five components, MIT, as the npm package
basecoat-css. The proposal is toadopt it as the vocabulary this project draws components from, instead of continuing to
grow our own.
Why now, and not later
The project is two weeks old: 296 commits since 2026-09-04, 0.3.0 tagged on 09-16, and
1.0.0 not due until after November. There are 179 templates and 11,375 lines of them —
which sounds like a lot to retrofit until you notice that 0.4.0 through 0.6.0 hold a dozen
issues that each write more interface: #160 (listings become a table), #205 (help text on
167 fields), #210 (two-column company form), #212 (footer), #213 (telephone and web
address rows), #253 (header-cell filters), #242 (backups in Server settings).
Every one of those is written against whatever vocabulary exists when it is written. The
cost of changing the vocabulary only goes up, and most of the interface this project will
ever have is still ahead of it. If it is worth doing at all, it is worth doing before
those land, not after.
What it actually costs to integrate
Less than it sounds, because it lands in a pipeline that already exists:
package.jsonalready runstailwindcss --input assets/css/app.css --output src/postulo/static/css/app.css, and the output is committed. Basecoat is authored forTailwind, so the CSS side is one
@import "basecoat-css"into a build we already run.people changing the CSS, exactly as
package.jsonsays today. No CDN — which would notpass the CSP anyway.
strings; the text stays in our Django templates and our 69 catalogues are untouched.
Only the interactive components introduce strings, and there are few of them.
What it does not change, and must not
Three things that look like gaps Basecoat would fill, and are decisions:
partials/confirm_delete.htmlcarries#217 (list the consequences — deleting one company took a year of applications with it)
and #227 (cancel goes where the view says, not the
Referer). A modal would cram thatlist into a box and break with scripts off.
partials/messages.htmlusesrole="alert"androle="status"in the flow of the page. A toast that dismisses itself on a timer is aworse answer to the same problem, and against the accessibility promise in the README.
<progress>.widgets/funnel.htmlexplains why in acomment: a value attribute satisfies the CSP where a styled SVG did not, and a screen
reader announces it without an
aria-labelrepeating the count. Basecoat's Chart isbeta and JS-driven; it is not an improvement on that.
The collision, named up front
Basecoat defines
btnandcard. We define@utility btnatassets/css/app.css:222and
.cardat:396. Importing both without a decision produces whichever the cascadehappens to pick. This has to be settled deliberately in the first commit — either our
layer converges onto Basecoat's names and the duplicates are deleted, or Basecoat is
imported under a prefix. Converging is the better end state and the classes are already
close (
btn,card,field-inputagainstbtn,card,input), but it is a renameacross many templates and should be its own commit.
Two smaller frictions worth knowing before starting:
ms/me,ps/pe,start/end).tests/test_template_lint.pywill fail on it until each component ispassed through.
convenience does not reach us; the markup gets copied by hand either way.
Three phases, in order
@import "basecoat-css", pick a theme, resolve thebtn/cardcollision, rebuild, and run the lint and axe suites. No template changes. This is the
commit that tells us what we are really dealing with, and it reverts cleanly.
templates that use the renamed classes, delete the duplicates from
@layer components. Mechanical, large diff, one commit.static/js/vendor/likehtmx.min.jsandzxcvbn. Each gets a no-script fallback, its strings extracted, anda clean axe run before the next one starts. Dropdown menu is the strongest candidate —
our menus are
<details>with hand-rolled outside-click handling inapp.js. Tabs andaccordion have no equivalent here at all.
Each phase closes on its own commit, and phase 1 can land without committing to 2 or 3.
Its relationship to #261 — read that first
#261 asks for suggestions on the three fields typed every time, and argues for
<datalist>over a hand-rolled combobox on three grounds: it needs no script, the browsersupplies
aria-expanded,aria-activedescendant, arrow keys, the touch keyboard and RTL,and a combobox is one of the widgets most often got wrong.
That reasoning does not stop applying because a library offers a combobox. #261's
decision governs; this issue does not override it. Basecoat's Combobox and Command are
out of scope here unless #261 is deliberately revisited, and the argument for revisiting
would have to be about what
<datalist>cannot do — a live endpoint for someone withhundreds of companies, which #261 already names as the point where something more is
needed.
That is worth stating plainly because it removes the single biggest advantage anybody
would claim for this change. What is left is a consistent vocabulary, a better-designed
default for components we have not written yet, and four or five interactive patterns we
genuinely lack. That is a real case, and a smaller one than it first appears.
What has to stay green
tests/test_template_lint.py(physical properties),tests/test_stylesheet.py(thecommitted CSS is not stale),
uv run pytest -m e2e --browser chromium(axe-core overevery page), and
scripts/messages.py extract --check && check. Both themes, WCAG 2.2 AA,keyboard and scripts-off, on every phase.
The rich select, tested — and it goes the same way as #261
A flag before a language's name is the case people usually reach a component library for,
because native HTML cannot do it. So it is worth checking what Basecoat actually offers,
and the answer trims this issue's case further.
Basecoat ships two:
<select>, so it cannot carry a flag at all. This is thesame wall as #88, already written down in
partials/phone_widget.html: "an<option>holds text and nothing else, in every browser, so no image can go in the list itself".
<button aria-haspopup="listbox">over<div role="option" data-value="…">rows, with the value in a hidden input. A<div>canhold a flag, so the flag is technically possible, though the documentation shows only
text in options.
It requires JavaScript and documents no fallback. The hidden input is populated by the
script and by nothing else, so with no script there is no selection. That fails the
project's standing rule anywhere, and this is the worst field to fail it on: somebody
whose script did not run cannot change the language, which may be the reason they opened
that page.
We already have this, with three decisions a generic component does not carry
templates/settings/locale.htmlis a<details>disclosure of radio rows — flag, thenname, then translation state. Flag before the name, and it opens, closes and submits with
no script. Beyond that it holds:
langon each name span — WCAG 3.1.2 Language of Parts, soΕλληνικάispronounced as Greek.
LanguageSelect(accounts/forms.py:192) does the same for theplain-select case, and its docstring explains why the translation state had to move out
to the group label.
silence.
a symbol claiming to be Greek while being neither Greek nor a word was the bug that
shaped the whole layout.
An imported Select knows none of that. Adopting it here would be a regression on three
counts and a loss of the no-script path.
What this means for #208 and #214
#208 asks for the flag in Server settings → Defaults, "as the language picker does", and
#214 asks for the same on postal addresses, "as the telephone field does". Both are asking
to reuse a pattern this repository already owns. The answer is to extract the picker in
settings/locale.html— and the flag-over-select overlay inphone_widget.html— intopartials those two can call. That is a refactor of our own code, not a library import, and
it does not depend on this issue.
Effect on this issue
Two of the strongest-sounding arguments for adopting Basecoat have now been checked and
both came back the other way: the combobox (#261, where
<datalist>is the better answer)and the rich select (here, where ours is better and script-free). What remains is
genuinely narrower — a consistent vocabulary, better defaults for components not yet
written, and the interactive patterns we actually lack, of which dropdown menu, tabs and
accordion are the honest list.
That is still a case for phase 1, which costs one
@importand reverts cleanly. It is aweaker case for phase 2 than when this was filed, and phase 3 should now be read as three
named components rather than an open door.
#263 proposes django-cotton for template composition, which is the orthogonal half of this question: cotton is the mechanism, this issue is whose CSS goes inside. Neither blocks the other.
One ordering consequence for this issue: if both are wanted, cotton goes first. With components in place, phase 2 here becomes a per-component migration; without them it is a per-template migration across 179 templates. That is worth settling before phase 2 is scheduled.
Landed in three commits —
0c794b6e3,0b00a26ea,f701952b8— and narrower than filed, as the thread predictedPhase 1, the import (
0c794b6e3). Not@import "basecoat-css". The bundle is the basetokens plus every component plus the Vega style pack, and each of the three collides with a
decision already made here: the base redefines the
darkvariant onto anhtml.darkclassnobody sets, swaps the font for Geist, changes every corner radius, colours every border and
makes the page
overscroll-nonefrom a base layer; the components bring a.cardand an.alertunder names this stylesheet owns; and a style pack is one file painting allforty-five components in greyscale tokens of its own. So Basecoat comes in one structural
file per adopted component (
basecoat-css/components/<name>.css), and the theme isPostulo's: its palette under Basecoat's token names (
--color-primary,--color-muted-foreground,--color-input, …), light values in@theme, dark ones under thedarkvariant every other rule uses.assets/css/basecoat.cssis the style pack — it paintsBasecoat's classes in Postulo's colours and defines nothing of its own.
The
btn/cardcollision is settled by ownership, with two tests:app.cssnever defines aclass or utility an imported Basecoat file defines, and
basecoat.csspaints only classes animported file defines. Import
card.csswhile.cardis ours and the suite fails before apage does. (Tailwind v4 refuses
@applyof a component-layer class, so "compose their.btninto our
.btn-primary" was never available; the utility was renamed for one commit and thendeleted.)
Phase 2, the vocabulary (
0b00a26ea). 291 buttons in 95 templates, three that chosetheir class inside a template tag, allauth's button element and two the script draws:
class="btn"withdata-variant="outline|ghost|destructive|destructive-ghost"anddata-size="sm|xs|icon|icon-sm|icon-xs". The size absorbs the padding utilities thetemplates had composed by hand (
px-2 py-1 text-xsthirty-eight times). Nothing on any pagelooks different; the five classes are gone and the template lint refuses them. Two structural
choices of Basecoat's are undone in the paint, on purpose:
whitespace-nowrap(Reflow at 320in forty languages) and
outline-none(forced colours keep only an outline — and it alsoleaves Tailwind's outline-style variable at
none, so the ring that goes back on has to saysolid; a browser test now checks a focused button under forced colours).Phase 3, the interactive components (
f701952b8). One adopted, one half, two writtendown:
<button>and a panel itsscript shows; with no script the panel stays hidden.
<c-dropdown-menu>puts its markupand stylesheet —
[data-popover],role="menu", role-keyed items, a separator — on a<details>the browser opens itself, andapp.jsadds the arrow keys, Home and End (italready closed on outside click and Escape). Its script is not vendored;
sync-vendor.mjssays why. First use: the people table's row actions, the only true menu of actions.
and the pattern for that is a disclosure, not a menu widget; it keeps its rows and takes
only Basecoat's popover box, painted once where five templates had copied it by hand.
nothing uses is a rule every page downloads. When a page needs one: the accordion is
<details>already and safe as it stands; tabs hide every panel but one from script, sothe no-script shape is all panels shown with in-page links, and
hiddenfrom the scripton top.
Kept as ours, with the reason in the source: card (Basecoat's pads its header, section
and footer, ours is a padded box holding anything, 250 of them), alert (four tones against
Basecoat's two), tag and chip (semantic tones Basecoat has no variants for), the fields
(#114's ids), plus everything the issue already ruled out — dialog, toast, chart, select,
combobox. Compiled stylesheet: 112.5 KB before, 110.6 KB after the three commits.
Both suites green locally at each phase (the one Windows-only flake in
test_submit_guardaside, green on rerun); CI runs the browser job on every push.