Skip to main content

max / makenotwork

Choose badge classes in Rust instead of interpolating status columns Thirteen templates wrote a status column straight into a class attribute (`class="badge {{ tx.status|lowercase }}"`), so the stylesheet had to mirror whatever the schema spelled. A column rename unstyled the badge silently, and a status nobody had written a rule for rendered plain: every subscription status other than `active`, and every scheduled item, was already doing that. `BadgeStatus` in src/types/badge.rs holds the charter's closed set. Each status enum maps into it with an exhaustive match, so a new variant is a compile error rather than an unstyled badge, and every status-bearing view model carries the resulting class. The raw-status CSS block phase 5 left behind goes with it. The set gains a neutral case for a terminal state nobody has to act on (Refunded, Canceled). It renders as the plain `.badge`, which already reads as neutral, so the vocabulary stays three colours wide: red would ask for an action that does not exist. Two smaller instances of the same leak: `.import-page .status-badge` was a page-scoped near-copy of `.badge` mirroring four ImportJobStatus spellings, and is now the badge it always was; `badge-role-{{ m.role }}` interpolated a role into a class no rule has ever matched, and is gone. AiTier keeps its own names, a disclosure level not being lifecycle, but sources them from `css_class()`.
Author: Max Johnson <me@maxj.phd> · 2026-08-02 23:45 UTC
Signed with PGP, not checked
Commit: b2ca5a4ce5172a733fb62461573f8276b9ae9086
Parent: 2e2d18a
28 files changed, +262 insertions, -55 deletions
@@ -92,7 +92,7 @@
92 92 | Section lead | `.section-lead` | with `.mb-section` / `.text-sm` / `.dimmed` utilities | — |
93 93 | Section divider | `.section-divider` | — | — |
94 94 | Section grouping label | `.section-group-label` | — | — |
95 - | Badge | `.badge` | status set `.badge--live` / `.badge--pending` / `.badge--failed`; `.free` (price fact); `.ai-tier-*` (disclosure level) | `.is-faded` |
95 + | Badge | `.badge` | status set `.badge--live` / `.badge--pending` / `.badge--failed`, no modifier for a neutral terminal state; `.free` (price fact); `.ai-tier-*` (disclosure level) | `.is-faded` |
96 96 | Tag | `.tag` (inside `.tag-input` for editing) | — | — |
97 97 | Callout (solid-tint inline) | `.callout` | `--danger`, `--warning`, `--solid-warning` | — |
98 98 | Alert (left-border inline) | `.alert` | `-note`, `-tip`, `-important`, `-warning`, `-caution` | — |
@@ -155,7 +155,9 @@
155 155 Hover used to have three options picked by component type, and the second of them was a depth lift: the shadow grew from `var(--shadow-raised)` to `3px 3px var(--shadow-edge)`, reserved for controls that commit a write, so that pressing "Pay" looked different from pressing "Next". That signal is gone rather than moved, and it was spent deliberately. A bevel is an edge and not a distance, so it has no growth axis to extend along; keeping a commit-versus-navigate cue would have meant a local override on top of the `layout.css` every other make-family app takes unmodified. Decided 2026-07-31 (wiki `mnw-platinum`). A control that commits a write is distinguished by its label and its placement, the same way it is in GoingsOn and Balanced Breakfast.
156 156 - **Focus**: `:focus-visible` shows the `--focus-ring` violet outline. Custom interactive containers (`.card--selectable`, sort headers) must opt in by adding `:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }`. A container the browser does not focus on its own also needs `tabindex="0"` in the markup, or the rule never matches and the outline is decoration for a state that cannot happen. The `data-action` dispatcher activates such a container on Enter and Space.
157 157 - **Selected / active**: `.is-selected` modifier applies `background: var(--highlight-faint)` plus the focus-ring border. `.tab.active`, `.filter-item.active`, `.view-btn.active`, and the `:checked + .card--selectable-inner` recipe all read it.
158 - - **Status**: a badge names the status it reports, and the colour follows from the status. The set is `.badge--live` (Enabled, Active, Available, Connected, Current), `.badge--pending` (Pending, Draft) and `.badge--failed` (Failed, Suspended). A badge reports data, not selection, so it never takes `.is-selected` and there is no `.badge.active`. A class that names a colour rather than a state is the same category error as `.mb-4` naming a number. `.free` and the `.ai-tier-*` trio stay outside the set: a price fact and a disclosure level are not lifecycle.
158 + - **Status**: a badge names the status it reports, and the colour follows from the status. The set is `.badge--live` (Active, Trialing, Completed, Approved, Published, Connected), `.badge--pending` (Pending, Processing, Scheduled, Draft, Incomplete) and `.badge--failed` (Failed, Past due, Unpaid, Suspended, Spam). A terminal state nobody has to act on (Refunded, Canceled) takes the plain `.badge` with no modifier, which already reads as neutral: colouring it red would ask for an action that does not exist. A badge reports data, not selection, so it never takes `.is-selected` and there is no `.badge.active`. A class that names a colour rather than a state is the same category error as `.mb-4` naming a number. `.free` and the `.ai-tier-*` trio stay outside the set: a price fact and a disclosure level are not lifecycle.
159 +
160 + The class is chosen in Rust, by `BadgeStatus` in `src/types/badge.rs`, and reaches the template as a `status_class` field. A template never interpolates a status column into a class attribute. That habit made the stylesheet mirror whatever the schema spelled, so a column rename unstyled the badge and a status nobody had written a rule for rendered plain, which is what "Scheduled" and every subscription status other than `active` did until 2026-08-02. Each status enum maps into the set with an exhaustive match, so a new variant is a compile error.
159 161 - **Disabled**: `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`.
160 162 - **Busy / loading**: HTMX-driven via `.htmx-request` on the trigger. There is no skeleton primitive: one was documented here for months and never built, so it was removed rather than left as a promise.
161 163
@@ -80,7 +80,7 @@
80 80 6. **Forms** (`.form-group`, `.form-section`, `.checkbox-group`)
81 81 7. **Tables** (`.data-table`, `.compact-table`)
82 82 8. **Utilities** (`.text-sm`, `.muted`, `.scroll-x`, etc.)
83 - 9. **Badges** (`.badge`, `.badge-success`, `.badge-danger`)
83 + 9. **Badges** (`.badge` plus the status set `.badge--live` / `.badge--pending` / `.badge--failed`)
84 84 10. **Cards** (`.card`, `.stat-card`, `.project-card`)
85 85 11. **Navigation** (`.tabs`, `.tab`, header/footer)
86 86 12. **Components** (modals, toasts, progress bars, onboarding)
@@ -1019,27 +1019,6 @@
1019 1019 background: var(--danger);
1020 1020 }
1021 1021
1022 - /* Raw-status classes. Several templates interpolate a status column straight
1023 - into the class attribute (`class="badge {{ tx.status|lowercase }}"`), so these
1024 - names are written by the data rather than chosen by an author. GoingsOn task
1025 - 8a007e42 moves that behind a status_class() helper on the view model, which
1026 - returns a name from the set above; this block goes when that lands. */
1027 - .badge.active,
1028 - .badge.current {
1029 - background: var(--success);
1030 - }
1031 -
1032 - .badge.pending,
1033 - .badge.draft,
1034 - .badge.suppressed {
1035 - background: var(--warning);
1036 - }
1037 -
1038 - .badge.failed,
1039 - .badge.suspended {
1040 - background: var(--danger);
1041 - }
1042 -
1043 1022 .badge.free {
1044 1023 background: var(--action);
1045 1024 color: var(--primary-light);
@@ -2585,18 +2564,6 @@
2585 2564 font-weight: bold;
2586 2565 }
2587 2566
2588 - .import-page .status-badge {
2589 - display: inline-block;
2590 - padding: var(--gap-bound) var(--gap-peer);
2591 - font-size: var(--text-fine);
2592 - font-family: var(--font-mono);
2593 - }
2594 -
2595 - .import-page .status-badge.completed { color: var(--success); }
2596 - .import-page .status-badge.processing { color: var(--action); }
2597 - .import-page .status-badge.failed { color: var(--danger); }
2598 - .import-page .status-badge.pending { opacity: 0.6; }
2599 -
2600 2567 .import-page .import-note {
2601 2568 background: var(--surface-sunken);
2602 2569 padding: var(--gap-section);
@@ -142,6 +142,18 @@
142 142 Spam => "spam",
143 143 });
144 144
145 + impl WaitlistStatus {
146 + /// Badge vocabulary (charter: `docs/design-system.md`).
147 + pub fn badge_status(self) -> crate::types::BadgeStatus {
148 + use crate::types::BadgeStatus;
149 + match self {
150 + Self::Approved => BadgeStatus::Live,
151 + Self::Pending => BadgeStatus::Pending,
152 + Self::Spam => BadgeStatus::Failed,
153 + }
154 + }
155 + }
156 +
145 157 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146 158 pub enum SelectionMethod {
147 159 #[serde(rename = "hand_picked")]
@@ -186,6 +198,20 @@
186 198 Failed => "failed",
187 199 });
188 200
201 + impl TransactionStatus {
202 + /// Badge vocabulary (charter: `docs/design-system.md`). A refund is over
203 + /// and needs nobody, so it is neutral rather than red.
204 + pub fn badge_status(self) -> crate::types::BadgeStatus {
205 + use crate::types::BadgeStatus;
206 + match self {
207 + Self::Completed => BadgeStatus::Live,
208 + Self::Pending | Self::Refunding => BadgeStatus::Pending,
209 + Self::Failed => BadgeStatus::Failed,
210 + Self::Refunded => BadgeStatus::Ended,
211 + }
212 + }
213 + }
214 +
189 215 // ── Follows ──
190 216
191 217 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -232,6 +258,21 @@
232 258 Unpaid => "unpaid",
233 259 });
234 260
261 + impl SubscriptionStatus {
262 + /// Badge vocabulary (charter: `docs/design-system.md`). A trial is live
263 + /// because the subscriber has access; a cancellation is over and needs
264 + /// nobody, so it is neutral rather than red.
265 + pub fn badge_status(self) -> crate::types::BadgeStatus {
266 + use crate::types::BadgeStatus;
267 + match self {
268 + Self::Active | Self::Trialing => BadgeStatus::Live,
269 + Self::Incomplete => BadgeStatus::Pending,
270 + Self::IncompleteExpired | Self::PastDue | Self::Unpaid => BadgeStatus::Failed,
271 + Self::Canceled => BadgeStatus::Ended,
272 + }
273 + }
274 + }
275 +
235 276 // ── SyncKit developer billing ──
236 277
237 278 /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status`
@@ -647,6 +688,17 @@
647 688 Self::Generated => "Generated",
648 689 }
649 690 }
691 +
692 + /// The badge modifier for this tier. A disclosure level is not lifecycle,
693 + /// so it keeps its own names rather than joining the status set, but the
694 + /// class still comes from here rather than from the serialized value.
695 + pub fn css_class(&self) -> &'static str {
696 + match self {
697 + Self::Handmade => "ai-tier-handmade",
698 + Self::Assisted => "ai-tier-assisted",
699 + Self::Generated => "ai-tier-generated",
700 + }
701 + }
650 702 }
651 703
652 704 /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
@@ -1042,6 +1094,18 @@
1042 1094 Failed => "failed",
1043 1095 });
1044 1096
1097 + impl ImportJobStatus {
1098 + /// Badge vocabulary (charter: `docs/design-system.md`).
1099 + pub fn badge_status(self) -> crate::types::BadgeStatus {
1100 + use crate::types::BadgeStatus;
1101 + match self {
1102 + Self::Completed => BadgeStatus::Live,
1103 + Self::Pending | Self::Processing => BadgeStatus::Pending,
1104 + Self::Failed => BadgeStatus::Failed,
1105 + }
1106 + }
1107 + }
1108 +
1045 1109 // Moderation action types
1046 1110
1047 1111 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -265,6 +265,8 @@
265 265 pub struct ImportJobRow {
266 266 pub source: String,
267 267 pub status: String,
268 + /// Badge class for `status`, from `ImportJobStatus::badge_status`.
269 + pub status_class: &'static str,
268 270 pub total_rows: i32,
269 271 pub created_rows: i32,
270 272 pub created_at: chrono::DateTime<chrono::Utc>,
@@ -13,6 +13,8 @@
13 13 pub email_verified: bool,
14 14 pub pitch: Option<String>,
15 15 pub status: String,
16 + /// Badge class for `status`, from `WaitlistStatus::badge_status`.
17 + pub status_class: &'static str,
16 18 pub selection_method: Option<String>,
17 19 pub admin_note: Option<String>,
18 20 pub created_at: String,
@@ -15,5 +15,7 @@
15 15 pub title: String,
16 16 pub slug: String,
17 17 pub status: String,
18 + /// Badge class for `status`, picked beside the label.
19 + pub status_class: &'static str,
18 20 pub published_at: String,
19 21 }