Adopt Basecoat for the component layer, while most of the interface is still unwritten #262

Closed
opened 2026-09-17 17:26:21 +00:00 by tiagoagueda · 3 comments
Owner

Postulo's component layer is hand-written and growing: @layer components in
assets/css/app.css is 819 lines and about thirty classes — btn, card, chip, tag,
field-input, alert-*, menu-item, state-glow, tap-target. Every one was written
here, 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 to
adopt 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.json already runs tailwindcss --input assets/css/app.css --output src/postulo/static/css/app.css, and the output is committed. Basecoat is authored for
    Tailwind, so the CSS side is one @import "basecoat-css" into a build we already run.
  • No runtime dependency and no deployment change. Node stays a build-time tool for
    people changing the CSS, exactly as package.json says today. No CDN — which would not
    pass the CSP anyway.
  • No translation cost for the CSS half. Basecoat's CSS-only components carry no
    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:

  • The delete confirmation stays a full page. partials/confirm_delete.html carries
    #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 that
    list into a box and break with scripts off.
  • Messages stay inline, not toasts. partials/messages.html uses role="alert" and
    role="status" in the flow of the page. A toast that dismisses itself on a timer is a
    worse answer to the same problem, and against the accessibility promise in the README.
  • The dashboard widgets keep <progress>. widgets/funnel.html explains why in a
    comment: a value attribute satisfies the CSP where a styled SVG did not, and a screen
    reader announces it without an aria-label repeating the count. Basecoat's Chart is
    beta and JS-driven; it is not an improvement on that.

The collision, named up front

Basecoat defines btn and card. We define @utility btn at assets/css/app.css:222
and .card at :396. Importing both without a decision produces whichever the cascade
happens 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-input against btn, card, input), but it is a rename
across many templates and should be its own commit.

Two smaller frictions worth knowing before starting:

  • Basecoat's markup is not written to our logical-property rule (ms/me, ps/pe,
    start/end). tests/test_template_lint.py will fail on it until each component is
    passed through.
  • The optional templates Basecoat ships are Nunjucks and Jinja, not Django. That
    convenience does not reach us; the markup gets copied by hand either way.

Three phases, in order

  1. Import only. Add @import "basecoat-css", pick a theme, resolve the btn/card
    collision, 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.
  2. Converge the vocabulary. Move our component layer onto Basecoat's names, update the
    templates that use the renamed classes, delete the duplicates from
    @layer components. Mechanical, large diff, one commit.
  3. Interactive components, one at a time. Vendored into static/js/vendor/ like
    htmx.min.js and zxcvbn. Each gets a no-script fallback, its strings extracted, and
    a clean axe run before the next one starts. Dropdown menu is the strongest candidate —
    our menus are <details> with hand-rolled outside-click handling in app.js. Tabs and
    accordion 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 browser
supplies 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 with
hundreds 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 (the
committed CSS is not stale), uv run pytest -m e2e --browser chromium (axe-core over
every page), and scripts/messages.py extract --check && check. Both themes, WCAG 2.2 AA,
keyboard and scripts-off, on every phase.

Postulo's component layer is hand-written and growing: `@layer components` in `assets/css/app.css` is 819 lines and about thirty classes — `btn`, `card`, `chip`, `tag`, `field-input`, `alert-*`, `menu-item`, `state-glow`, `tap-target`. Every one was written here, and every new screen either reuses one or adds another. [Basecoat](https://basecoatui.com/) 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 to adopt 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.json` already runs `tailwindcss --input assets/css/app.css --output src/postulo/static/css/app.css`, and the output is committed. Basecoat is authored for Tailwind, so the CSS side is one `@import "basecoat-css"` into a build we already run. - **No runtime dependency and no deployment change.** Node stays a build-time tool for people changing the CSS, exactly as `package.json` says today. No CDN — which would not pass the CSP anyway. - **No translation cost for the CSS half.** Basecoat's CSS-only components carry no 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: - **The delete confirmation stays a full page.** `partials/confirm_delete.html` carries #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 that list into a box and break with scripts off. - **Messages stay inline, not toasts.** `partials/messages.html` uses `role="alert"` and `role="status"` in the flow of the page. A toast that dismisses itself on a timer is a worse answer to the same problem, and against the accessibility promise in the README. - **The dashboard widgets keep `<progress>`.** `widgets/funnel.html` explains why in a comment: a value attribute satisfies the CSP where a styled SVG did not, and a screen reader announces it without an `aria-label` repeating the count. Basecoat's Chart is beta and JS-driven; it is not an improvement on that. ## The collision, named up front Basecoat defines `btn` and `card`. We define `@utility btn` at `assets/css/app.css:222` and `.card` at `:396`. Importing both without a decision produces whichever the cascade happens 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-input` against `btn`, `card`, `input`), but it is a rename across many templates and should be its own commit. Two smaller frictions worth knowing before starting: - Basecoat's markup is not written to our logical-property rule (`ms`/`me`, `ps`/`pe`, `start`/`end`). `tests/test_template_lint.py` will fail on it until each component is passed through. - The optional templates Basecoat ships are Nunjucks and Jinja, not Django. That convenience does not reach us; the markup gets copied by hand either way. ## Three phases, in order 1. **Import only.** Add `@import "basecoat-css"`, pick a theme, resolve the `btn`/`card` collision, 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. 2. **Converge the vocabulary.** Move our component layer onto Basecoat's names, update the templates that use the renamed classes, delete the duplicates from `@layer components`. Mechanical, large diff, one commit. 3. **Interactive components, one at a time.** Vendored into `static/js/vendor/` like `htmx.min.js` and `zxcvbn`. Each gets a no-script fallback, its strings extracted, and a clean axe run before the next one starts. Dropdown menu is the strongest candidate — our menus are `<details>` with hand-rolled outside-click handling in `app.js`. Tabs and accordion 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 browser supplies `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 with hundreds 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` (the committed CSS is not stale), `uv run pytest -m e2e --browser chromium` (axe-core over every page), and `scripts/messages.py extract --check && check`. Both themes, WCAG 2.2 AA, keyboard and scripts-off, on every phase.
tiagoagueda added this to the 0.4.0 milestone 2026-09-17 17:26:21 +00:00
Author
Owner

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:

  • Native Select is a styled <select>, so it cannot carry a flag at all. This is the
    same 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".
  • Select is a JS listbox — <button aria-haspopup="listbox"> over <div role="option" data-value="…"> rows, with the value in a hidden input. A <div> can
    hold 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.html is a <details> disclosure of radio rows — flag, then
name, then translation state. Flag before the name, and it opens, closes and submits with
no script. Beyond that it holds:

  • lang on each name span — WCAG 3.1.2 Language of Parts, so Ελληνικά is
    pronounced as Greek. LanguageSelect (accounts/forms.py:192) does the same for the
    plain-select case, and its docstring explains why the translation state had to move out
    to the group label.
  • The flag marked decorative (#88), because "Greek flag, Ελληνικά" is worse than
    silence.
  • The translation-state badge outside the lang-tagged span, in the interface language —
    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 in phone_widget.html — into
partials 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 @import and reverts cleanly. It is a
weaker 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.

## 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: - **Native Select** is a styled `<select>`, so it cannot carry a flag at all. This is the same 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". - **Select** is a JS listbox — `<button aria-haspopup="listbox">` over `<div role="option" data-value="…">` rows, with the value in a hidden input. A `<div>` can hold 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.html` is a `<details>` disclosure of radio rows — flag, then name, then translation state. Flag before the name, and it opens, closes and submits with no script. Beyond that it holds: - **`lang` on each name span** — WCAG 3.1.2 Language of Parts, so `Ελληνικά` is pronounced as Greek. `LanguageSelect` (`accounts/forms.py:192`) does the same for the plain-select case, and its docstring explains why the translation state had to move out to the group label. - **The flag marked decorative** (#88), because "Greek flag, Ελληνικά" is worse than silence. - **The translation-state badge outside the lang-tagged span**, in the interface language — 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 in `phone_widget.html` — into partials 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 `@import` and reverts cleanly. It is a weaker 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.
Author
Owner

#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.

#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.
Author
Owner

Landed in three commits — 0c794b6e3, 0b00a26ea, f701952b8 — and narrower than filed, as the thread predicted

Phase 1, the import (0c794b6e3). Not @import "basecoat-css". The bundle is the base
tokens plus every component plus the Vega style pack, and each of the three collides with a
decision already made here: the base redefines the dark variant onto an html.dark class
nobody sets, swaps the font for Geist, changes every corner radius, colours every border and
makes the page overscroll-none from a base layer; the components bring a .card and an
.alert under names this stylesheet owns; and a style pack is one file painting all
forty-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 is
Postulo's
: its palette under Basecoat's token names (--color-primary,
--color-muted-foreground, --color-input, …), light values in @theme, dark ones under the
dark variant every other rule uses. assets/css/basecoat.css is the style pack — it paints
Basecoat's classes in Postulo's colours and defines nothing of its own.

The btn/card collision is settled by ownership, with two tests: app.css never defines a
class or utility an imported Basecoat file defines, and basecoat.css paints only classes an
imported file defines. Import card.css while .card is ours and the suite fails before a
page does. (Tailwind v4 refuses @apply of a component-layer class, so "compose their .btn
into our .btn-primary" was never available; the utility was renamed for one commit and then
deleted.)

Phase 2, the vocabulary (0b00a26ea). 291 buttons in 95 templates, three that chose
their class inside a template tag, allauth's button element and two the script draws:
class="btn" with data-variant="outline|ghost|destructive|destructive-ghost" and
data-size="sm|xs|icon|icon-sm|icon-xs". The size absorbs the padding utilities the
templates had composed by hand (px-2 py-1 text-xs thirty-eight times). Nothing on any page
looks 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 320
in forty languages) and outline-none (forced colours keep only an outline — and it also
leaves Tailwind's outline-style variable at none, so the ring that goes back on has to say
solid; a browser test now checks a focused button under forced colours).

Phase 3, the interactive components (f701952b8). One adopted, one half, two written
down:

  • Dropdown menu — adopted, not as shipped. Basecoat's is a <button> and a panel its
    script shows; with no script the panel stays hidden. <c-dropdown-menu> puts its markup
    and stylesheet — [data-popover], role="menu", role-keyed items, a separator — on a
    <details> the browser opens itself, and app.js adds the arrow keys, Home and End (it
    already closed on outside click and Escape). Its script is not vendored; sync-vendor.mjs
    says why. First use: the people table's row actions, the only true menu of actions.
  • Popover — the half. The account menu is navigation (three links, a switch, sign-out),
    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.
  • Tabs and accordion — not built. Nothing in the interface is either of them, and a rule
    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, so
    the no-script shape is all panels shown with in-page links, and hidden from the script
    on 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_guard
aside, green on rerun); CI runs the browser job on every push.

## Landed in three commits — 0c794b6e3, 0b00a26ea, f701952b8 — and narrower than filed, as the thread predicted **Phase 1, the import (0c794b6e3).** Not `@import "basecoat-css"`. The bundle is the base tokens plus every component plus the Vega style pack, and each of the three collides with a decision already made here: the base redefines the `dark` variant onto an `html.dark` class nobody sets, swaps the font for Geist, changes every corner radius, colours every border and makes the page `overscroll-none` from a base layer; the components bring a `.card` and an `.alert` under names this stylesheet owns; and a style pack is one file painting all forty-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 is Postulo's**: its palette under Basecoat's token names (`--color-primary`, `--color-muted-foreground`, `--color-input`, …), light values in `@theme`, dark ones under the `dark` variant every other rule uses. `assets/css/basecoat.css` is the style pack — it paints Basecoat's classes in Postulo's colours and defines nothing of its own. The `btn`/`card` collision is settled by ownership, with two tests: `app.css` never defines a class or utility an imported Basecoat file defines, and `basecoat.css` paints only classes an imported file defines. Import `card.css` while `.card` is ours and the suite fails before a page does. (Tailwind v4 refuses `@apply` of a component-layer class, so "compose their `.btn` into our `.btn-primary`" was never available; the utility was renamed for one commit and then deleted.) **Phase 2, the vocabulary (0b00a26ea).** 291 buttons in 95 templates, three that chose their class inside a template tag, allauth's button element and two the script draws: `class="btn"` with `data-variant="outline|ghost|destructive|destructive-ghost"` and `data-size="sm|xs|icon|icon-sm|icon-xs"`. The size absorbs the padding utilities the templates had composed by hand (`px-2 py-1 text-xs` thirty-eight times). Nothing on any page looks 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 320 in forty languages) and `outline-none` (forced colours keep only an outline — and it also leaves Tailwind's outline-style variable at `none`, so the ring that goes back on has to say `solid`; a browser test now checks a focused button under forced colours). **Phase 3, the interactive components (f701952b8).** One adopted, one half, two written down: - **Dropdown menu — adopted, not as shipped.** Basecoat's is a `<button>` and a panel its script shows; with no script the panel stays hidden. `<c-dropdown-menu>` puts its markup and stylesheet — `[data-popover]`, `role="menu"`, role-keyed items, a separator — on a `<details>` the browser opens itself, and `app.js` adds the arrow keys, Home and End (it already closed on outside click and Escape). Its script is not vendored; `sync-vendor.mjs` says why. First use: the people table's row actions, the only true menu of actions. - **Popover — the half.** The account menu is navigation (three links, a switch, sign-out), 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. - **Tabs and accordion — not built.** Nothing in the interface is either of them, and a rule 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, so the no-script shape is all panels shown with in-page links, and `hidden` from the script on 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_guard` aside, green on rerun); CI runs the browser job on every push.
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#262
No description provided.