Take Basecoat's boxes and states: a table header copied 13 times, 47 empty states in 5 shapes, a pill written 7 ways #291

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

The companion to #290. That one takes Basecoat's form family, which arrives without a rename because Postulo owns field-input and not field. This one is the harder half — the boxes and the states — and it is where the duplication actually is.

The evidence, counted over the 184 templates

A table header is copied thirteen times. The string

<thead class="border-b border-ink-200 text-xs text-ink-500 dark:border-ink-800 dark:text-ink-400">

appears 13 times across 12 files, and <table class="w-full text-start text-sm"> 12 times. Sixteen templates contain a <table>; two use <c-table.head>. Worse, the abstraction exists twice — cotton/table/head.html and allauth/elements/thead.html each hold a byte-identical copy of that string — and it is still written out eleven more times. Of 84 <th> elements, 39 carry class="px-4 py-3 font-medium", and the row rule is drawn two incompatible ways: <tr class="border-b …"> five times against <tbody class="divide-y …"> eight times.

"There is nothing here" is written about forty-seven times, in five shapes. The same sentence is centred in one place and left-aligned in another:

Shape Class string Times
Centred card, muted line card py-12 text-center text-sm text-ink-500 dark:text-ink-400 10
Centred card, heading + body + action card py-12 text-center 9
Left-aligned muted card card text-sm text-ink-500 dark:text-ink-400 17
Inside a list box px-4 py-6 text-sm text-ink-500 dark:text-ink-400 5
Board column, dashed rounded-lg border border-dashed … 1

Two list pages, the same sentence, different boxes: accounts/invite_list.html:63 centres "No invitations yet." without the padding, applications/tag_list.html:44 centres "No tags yet." with it. The three plugin pages disagree among themselves — two use the list shape, the third uses a card.

A pill is implemented seven ways, and the class that exists is used once. app.css:380-410 defines .tag and seven colours. tag-grey is used once, in partials/plugin_tags.html:22; the other six colours are dead code. Meanwhile the literal string rounded-full bg-ink-100 px-2 py-0.5 text-xs is written inline 9 times, in two ink tones (600 five times, 700 three), with font-medium present in the partials and absent in every inline copy — which is the only thing separating them from the real class. server/logs.html:99 draws a square one with its own three-branch tone map, duplicating partials/status_badge.html. documents/cv_list.html:33 gets a badge by writing chip py-0 text-xs.

card appears 234 times in 31 distinct class strings, with four bottom margins for one stacked layout: mb-6 (62), mb-4 (19), mb-8 (11), none (26).

What Basecoat has, and what collides

Postulo Uses Basecoat Collides?
card (app.css:455) 234 card — a grid with > header, an action slot, > footer same name
alert + four variants 85 alert — > section, > footer, :has(> footer) spacing same name
tag + 7 colours 1 badge no — and that is the trap
chip, chip-new, chip-remove, chip-text built by app.js badge, input-group no
menu-item 5 item no
table-cards, scroll-x 2 table, table-container no
— — empty (26 lines) nothing to collide with
— — skeleton (6 lines) nothing to collide with
— — kbd, breadcrumb, avatar, progress none exist here

All of them are pure CSS with no script, so the objection recorded in scripts/sync-vendor.mjs — that a Basecoat component driven by script has no path with scripts off — does not reach any of them.

tests/test_stylesheet.py::test_no_class_is_defined_on_both_sides_of_the_import refuses an import whose classes app.css also defines, so card and alert each need a decision taken in the open. That guard is why this has not happened by drift. The trap is the row it cannot catch: tag and badge are two names for one idea, so importing badge would pass the guard and leave the application with two ways to draw a pill — worse than either. The decision is per concept, not per name.

The order

One component per commit, as #262 established, each with its decision written down first:

  1. empty, skeleton, kbd — nothing to decide and nothing to collide with. empty replaces 47 hand-written blocks with one component; skeleton is the loading state the application has never had. These are pure gain, they are small, and they prove the paint pattern.
  2. table — with <c-table.head> becoming the only table header, the thirteenth copy deleted, and the allauth duplicate pointed at the same component.
  3. item, breadcrumb, avatar, progress — no name collision, but menu-item, the <title>-only Server settings › trail, the initials tile built in core/templatetags/postulo.py and the funnel bar are the same ideas under other names, so each replaces rather than joins.
  4. badge — tag and chip go with it, the nine inline copies with them, and the seven-colour palette moves onto badge variants, which is what #285 then spends.
  5. card — the big one, 234 sites, and the semantic <header>/<footer> is the point rather than the cost.
  6. alert — last, because the page-wide region is wired into app.js, htmx and the Escape handler, and that is where getting it wrong is silent.

What must not change

The full constraint list is in #292. The ones that bite hardest here:

  • Forced colours. app.css:944-979 puts a ButtonText border on menu-item, nav-link, chip-remove and the funnel bar, because a high-contrast theme discards backgrounds. Every replacement needs its line there — a card that says "card" only by its background says nothing under that theme.
  • test_template_lint.py forbids overflow-auto and overflow-x-auto in templates: a table scrolls in .scroll-x. If table-container takes over, the rule moves rather than lapses, and .scroll-x's relative is load-bearing — an .sr-only descendant escaping the box scrolled every listing page 144 px at 320.
  • table-cards (app.css:608-638) turns a table into cards below 48rem, measured against real pages rather than guessed. Basecoat's table has no equivalent, so it survives whatever else changes.
  • skeleton is animate-pulse, and app.css:897-906 already reduces every animation to 0.01ms for somebody who asked for less motion. Extend tests/e2e/test_text_spacing.py::test_less_motion_means_no_motion to a page showing one, rather than assuming.
  • axe over every page in both themes with no excused rules; target size 24×24 with no exemptions; reflow at 320 in English, Greek and German.

Done when

  • Every concept in the table above is drawn by exactly one thing, and its name says which.
  • empty and skeleton are used by every list and every slow page that has neither.
  • One table header exists, and allauth/elements/thead.html uses it rather than copying it.
  • test_stylesheet.py's ownership guard passes with the new imports, and basecoat.css paints only classes Basecoat defines.
  • The forced-colours blocks cover the replacements, and every browser check passes unchanged.
The companion to #290. That one takes Basecoat's form family, which arrives without a rename because Postulo owns `field-input` and not `field`. This one is the harder half — the boxes and the states — and it is where the duplication actually is. ## The evidence, counted over the 184 templates **A table header is copied thirteen times.** The string ```html <thead class="border-b border-ink-200 text-xs text-ink-500 dark:border-ink-800 dark:text-ink-400"> ``` appears **13 times across 12 files**, and `<table class="w-full text-start text-sm">` **12 times**. Sixteen templates contain a `<table>`; **two** use `<c-table.head>`. Worse, the abstraction exists *twice* — `cotton/table/head.html` and `allauth/elements/thead.html` each hold a byte-identical copy of that string — and it is still written out eleven more times. Of 84 `<th>` elements, **39 carry `class="px-4 py-3 font-medium"`**, and the row rule is drawn two incompatible ways: `<tr class="border-b …">` five times against `<tbody class="divide-y …">` eight times. **"There is nothing here" is written about forty-seven times, in five shapes.** The same sentence is centred in one place and left-aligned in another: | Shape | Class string | Times | | --- | --- | --- | | Centred card, muted line | `card py-12 text-center text-sm text-ink-500 dark:text-ink-400` | 10 | | Centred card, heading + body + action | `card py-12 text-center` | 9 | | **Left-aligned** muted card | `card text-sm text-ink-500 dark:text-ink-400` | **17** | | Inside a list box | `px-4 py-6 text-sm text-ink-500 dark:text-ink-400` | 5 | | Board column, dashed | `rounded-lg border border-dashed …` | 1 | Two list pages, the same sentence, different boxes: `accounts/invite_list.html:63` centres "No invitations yet." without the padding, `applications/tag_list.html:44` centres "No tags yet." with it. The three plugin pages disagree among themselves — two use the list shape, the third uses a card. **A pill is implemented seven ways, and the class that exists is used once.** `app.css:380-410` defines `.tag` and seven colours. **`tag-grey` is used once, in `partials/plugin_tags.html:22`; the other six colours are dead code.** Meanwhile the literal string `rounded-full bg-ink-100 px-2 py-0.5 text-xs` is written inline **9 times**, in two ink tones (`600` five times, `700` three), with `font-medium` present in the partials and absent in every inline copy — which is the only thing separating them from the real class. `server/logs.html:99` draws a square one with its own three-branch tone map, duplicating `partials/status_badge.html`. `documents/cv_list.html:33` gets a badge by writing `chip py-0 text-xs`. **`card` appears 234 times in 31 distinct class strings**, with four bottom margins for one stacked layout: `mb-6` (62), `mb-4` (19), `mb-8` (11), none (26). ## What Basecoat has, and what collides | Postulo | Uses | Basecoat | Collides? | | --- | --- | --- | --- | | `card` (`app.css:455`) | 234 | `card` — a grid with `> header`, an action slot, `> footer` | **same name** | | `alert` + four variants | 85 | `alert` — `> section`, `> footer`, `:has(> footer)` spacing | **same name** | | `tag` + 7 colours | 1 | `badge` | no — **and that is the trap** | | `chip`, `chip-new`, `chip-remove`, `chip-text` | built by `app.js` | `badge`, `input-group` | no | | `menu-item` | 5 | `item` | no | | `table-cards`, `scroll-x` | 2 | `table`, `table-container` | no | | — | — | **`empty`** (26 lines) | nothing to collide with | | — | — | **`skeleton`** (6 lines) | nothing to collide with | | — | — | `kbd`, `breadcrumb`, `avatar`, `progress` | none exist here | All of them are **pure CSS with no script**, so the objection recorded in `scripts/sync-vendor.mjs` — that a Basecoat component driven by script has no path with scripts off — does not reach any of them. `tests/test_stylesheet.py::test_no_class_is_defined_on_both_sides_of_the_import` refuses an import whose classes `app.css` also defines, so `card` and `alert` each need a decision taken in the open. That guard is why this has not happened by drift. **The trap is the row it cannot catch:** `tag` and `badge` are two names for one idea, so importing `badge` would pass the guard and leave the application with two ways to draw a pill — worse than either. The decision is per *concept*, not per name. ## The order One component per commit, as #262 established, each with its decision written down first: 1. **`empty`, `skeleton`, `kbd`** — nothing to decide and nothing to collide with. `empty` replaces 47 hand-written blocks with one component; `skeleton` is the loading state the application has never had. These are pure gain, they are small, and they prove the paint pattern. 2. **`table`** — with `<c-table.head>` becoming the only table header, the thirteenth copy deleted, and the `allauth` duplicate pointed at the same component. 3. **`item`, `breadcrumb`, `avatar`, `progress`** — no name collision, but `menu-item`, the `<title>`-only *Server settings ›* trail, the initials tile built in `core/templatetags/postulo.py` and the funnel bar are the same ideas under other names, so each replaces rather than joins. 4. **`badge`** — `tag` and `chip` go with it, the nine inline copies with them, and the seven-colour palette moves onto badge variants, which is what **#285** then spends. 5. **`card`** — the big one, 234 sites, and the semantic `<header>`/`<footer>` is the point rather than the cost. 6. **`alert`** — last, because the page-wide region is wired into `app.js`, htmx and the Escape handler, and that is where getting it wrong is silent. ## What must not change The full constraint list is in #292. The ones that bite hardest here: - **Forced colours.** `app.css:944-979` puts a `ButtonText` border on `menu-item`, `nav-link`, `chip-remove` and the funnel bar, because a high-contrast theme discards backgrounds. Every replacement needs its line there — a card that says "card" only by its background says nothing under that theme. - **`test_template_lint.py`** forbids `overflow-auto` and `overflow-x-auto` in templates: a table scrolls in `.scroll-x`. If `table-container` takes over, the rule moves rather than lapses, and `.scroll-x`'s `relative` is load-bearing — an `.sr-only` descendant escaping the box scrolled every listing page 144 px at 320. - **`table-cards`** (`app.css:608-638`) turns a table into cards below 48rem, measured against real pages rather than guessed. Basecoat's `table` has no equivalent, so it survives whatever else changes. - **`skeleton` is `animate-pulse`**, and `app.css:897-906` already reduces every animation to 0.01ms for somebody who asked for less motion. Extend `tests/e2e/test_text_spacing.py::test_less_motion_means_no_motion` to a page showing one, rather than assuming. - axe over every page in both themes with no excused rules; target size 24×24 with no exemptions; reflow at 320 in English, Greek and German. ## Done when - Every concept in the table above is drawn by exactly one thing, and its name says which. - `empty` and `skeleton` are used by every list and every slow page that has neither. - One table header exists, and `allauth/elements/thead.html` uses it rather than copying it. - `test_stylesheet.py`'s ownership guard passes with the new imports, and `basecoat.css` paints only classes Basecoat defines. - The forced-colours blocks cover the replacements, and every browser check passes unchanged.
tiagoagueda added this to the 0.5.0 milestone 2026-09-19 17:40:44 +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#291
No description provided.