Skip to main content

max / makenotwork

Take makeover's badge: coloured text and a data-tone, not a painted pill The B2 pass let the badge fill survive on the argument that makeover has no filled form, so the fill could not come from the generated sheet, and that given a near-black ground the generated --content-muted would fail contrast. The second half is circular — it only holds GIVEN the fill — and the first half is the shape of every "we already had it this way" argument. The test is whether the conversion removes a purpose without supplying it. The fill's purpose was colouring a badge by status, and layout.css's .badge[data-tone="…"] supplies exactly that, so the fill goes. .badge the pill's ground and label colour, deleted. It is the generated coloured text now. .badge--live/pending/ deleted. Three fills saying what the tone says. failed .badge.free deleted; the feed's Free badge takes tone success. .badge-active/-trusted deleted. An ad-hoc second spelling of success, in the admin entries. .feed-page .badge keeps its caps and tracking, loses its sunken fill. .badge--founder-* not statuses. Locked-for-life is emphasis and pending-lock-in is an aside, which as coloured text is full-strength ink against muted. .badge-new not a status either: the accent hue with weight, since New is the one badge meant to catch the eye. .badge.ai-tier-* stays, and this one is not a preserved look. makeover's four tones are info/success/warning/ danger and all four read as alarm. A disclosure level is not alarm: a handmade item is not a "success" and a generated one is not a "danger". The three hues stay local as `color`, taking the generated form and keeping only what it cannot say. BadgeStatus::css_class() becomes tone(), returning the makeover tone name and "" for the neutral terminal state. Empty is deliberate rather than a special case at the call site: data-tone="" matches no attribute selector, so every template is one unconditional attribute. The view-model field renames status_class -> status_tone across the eight types that carry it, and its test now reads layout.css rather than style.css — the vocabulary is makeover's, so the test should fail if a regeneration drops a tone, not pass against a local rule we wrote to match. Charter and the style.css header updated to match.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 16:14 UTC
Signed with PGP, not checked
Commit: e5ab32c01a389e47f54a9f6bb7f94225f7d81030
Parent: eeb930b
34 files changed, +140 insertions, -132 deletions
@@ -100,7 +100,7 @@
100 100 | Section lead | `.section-lead` | with `.mb-section` / `.text-sm` / `.dimmed` utilities | — |
101 101 | Section divider | `.section-divider` | — | — |
102 102 | Section grouping label | `.section-group-label` | — | — |
103 - | 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` |
103 + | Badge | `.badge` | status via `data-tone="success" / "warning" / "danger"`, no tone for a neutral terminal state; `.ai-tier-*` (disclosure level) | `.is-faded` |
104 104 | Tag | `.tag` (inside `.tag-input` for editing) | — | — |
105 105 | Callout (solid-tint inline) | `.callout` | `--danger`, `--warning`, `--solid-warning` | — |
106 106 | Alert (left-border inline) | `.alert` | `-note`, `-tip`, `-important`, `-warning`, `-caution` | — |
@@ -163,9 +163,11 @@
163 163 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.
164 164 - **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.
165 165 - **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.
166 - - **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.
166 + - **Status**: a badge names the status it reports, and the colour follows from the status. The set is `data-tone="success"` (Active, Trialing, Completed, Approved, Published, Connected, Free), `data-tone="warning"` (Pending, Processing, Scheduled, Draft, Incomplete) and `data-tone="danger"` (Failed, Past due, Unpaid, Suspended, Spam). A terminal state nobody has to act on (Refunded, Canceled) takes the plain `.badge` with no tone, 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.
167 167
168 - 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.
168 + The tone is an attribute rather than a class because that is makeover's spelling: `layout.css` carries `.badge[data-tone="…"]` and the four tone names are the design system's. The badge itself is coloured text, not a filled pill. It was a filled pill until wave 2 tier B2, painted per status by a local `.badge--live/pending/failed` set, and that fill was saying exactly what the generated colour says — keeping it meant the tone could never do anything. `.ai-tier-*` is the one thing that stays local, because makeover's four tones all read as alarm and a disclosure level is not alarm: a handmade item is not a "success".
169 +
170 + The tone is chosen in Rust, by `BadgeStatus` in `src/types/badge.rs`, and reaches the template as a `status_tone` 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.
169 171 - **Disabled**: `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`.
170 172 - **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.
171 173
@@ -44,7 +44,7 @@
44 44 .card / .card-muted / .card--bordered / .card--selectable
45 45 .form-group / .form-section / details.form-section
46 46 .section-header / .section-lead / .section-group-label
47 - .badge + variants (-success/-warning/-danger)
47 + .badge + data-tone (success/warning/danger)
48 48 .callout + --danger / --warning / --solid-warning
49 49 .banner + --info / --warning full-bleed page-top notice
50 50 .alert + -note / -tip / -warning / -caution left-border inline notice
@@ -1034,59 +1034,44 @@
1034 1034 BADGES
1035 1035 =========================================== */
1036 1036
1037 + /* Geometry and type only. The colour is layout.css's: a badge is coloured
1038 + text on the page, and its status colour comes from `data-tone` — see
1039 + src/types/badge.rs, which maps every status enum onto a tone name.
1040 +
1041 + This used to be a filled pill (near-black ground, --primary-light label)
1042 + with a local `.badge--live/pending/failed` set painting the fill per
1043 + status. All of it went in wave 2 tier B2: the fill was carrying exactly
1044 + what the generated `color` carries, so it was the same thing said in a
1045 + different channel, and keeping it meant the tone attribute could never do
1046 + anything. */
1037 1047 .badge {
1038 1048 display: inline-block;
1039 1049 padding: var(--gap-bound) var(--gap-peer);
1040 1050 font-size: var(--text-fine);
1041 1051 font-family: var(--font-mono);
1042 - /* respec-ok: makeover's badge is coloured text on the page, and has no
1043 - filled form at all — it sets `color` and never a background. MNW's badge
1044 - is a filled pill and the fill is what the whole `.badge--*` status
1045 - vocabulary in the charter colours, so the fill cannot come from the
1046 - generated sheet. Given the fill, the generated `color: --content-muted`
1047 - is not merely different, it is muted content colour on a near-black
1048 - ground and fails contrast, so --primary-light stands. */
1049 - background: var(--primary-dark);
1050 - color: var(--primary-light);
1051 - }
1052 -
1053 - /* Status vocabulary (charter: docs/design-system.md). A badge names the status
1054 - it reports, and the colour follows from the status. */
1055 - .badge--live {
1056 - background: var(--success);
1057 - }
1058 -
1059 - .badge--pending {
1060 - background: var(--warning);
1061 - }
1062 -
1063 - .badge--failed {
1064 - background: var(--danger);
1065 - }
1066 -
1067 - .badge.free {
1068 - background: var(--action);
1069 - color: var(--primary-light);
1070 1052 }
1071 1053
1072 1054 /* AI disclosure tier badges. See site-docs/public/about/generative-ai.md.
1073 - Colors track the brand palette, violet for the cleanest case,
1074 - warm-tan for disclosed-AI-use, charcoal for primarily-generated.
1075 - Deliberately not red/yellow/green; this is disclosure, not alarm. */
1055 + Colours track the brand palette: violet for the cleanest case, warm-tan for
1056 + disclosed AI use, charcoal for primarily generated.
1057 +
1058 + respec-ok: this is a taxonomy makeover has no vocabulary for. Its four
1059 + tones are info / success / warning / danger, which all read as alarm, and
1060 + disclosure is not alarm — a handmade item is not a "success" and a
1061 + generated one is not a "danger". So the three hues stay local. They set
1062 + `color` rather than a background, because the badge form is the generated
1063 + one now and only the hue is ours. */
1076 1064 .badge.ai-tier {
1077 1065 letter-spacing: 0.02em;
1078 1066 }
1079 1067 .badge.ai-tier-handmade {
1080 - background: var(--action);
1081 - color: var(--primary-light);
1068 + color: var(--action);
1082 1069 }
1083 1070 .badge.ai-tier-assisted {
1084 - background: var(--warning);
1085 - color: var(--primary-light);
1071 + color: var(--warning);
1086 1072 }
1087 1073 .badge.ai-tier-generated {
1088 - background: var(--content);
1089 - color: var(--primary-light);
1074 + color: var(--content);
1090 1075 }
1091 1076
1092 1077 .item-ai-tier {
@@ -3437,10 +3422,11 @@
3437 3422
3438 3423 .feed-page .item-creator { font-size: var(--text-fine); opacity: 0.5; }
3439 3424
3425 + /* The feed's badges shout a little: caps and tracking. The sunken fill that
3426 + used to come with them went with the filled-badge form in B2. */
3440 3427 .feed-page .badge {
3441 3428 text-transform: uppercase;
3442 3429 letter-spacing: 0.03em;
3443 - background: var(--surface-sunken);
3444 3430 }
3445 3431
3446 3432 .feed-page .empty-state {
@@ -5142,7 +5128,7 @@
5142 5128 /* Canonical "selected" recipe (charter: docs/design-system.md).
5143 5129 Apply `.is-selected` to any interactive container to mark it as the
5144 5130 currently-selected option. A badge never takes it: a badge reports a status
5145 - and nobody selects "Enabled", so badges use the `.badge--*` status set.
5131 + and nobody selects "Enabled", so badges take a `data-tone` instead.
5146 5132 Visibility toggles (`.tab-content.active`, `.section-panel.active`,
5147 5133 `.editor-panel.active`) also keep `.active` since they're not selection
5148 5134 state. */
@@ -8260,11 +8246,12 @@
8260 8246 padding: var(--gap-section); opacity: 0.4; font-style: italic;
8261 8247 }
8262 8248
8263 - /* Library "New" badge for items with undownloaded versions */
8249 + /* Library "New" badge for items with undownloaded versions. Not a status, so
8250 + it takes no tone; it is the plain badge with the accent hue and a little
8251 + more weight, since "New" is the one badge meant to catch the eye. */
8264 8252 .badge-new {
8265 - background: var(--action); color: var(--primary-light); font-size: var(--text-fine);
8266 - padding: var(--gap-bound) var(--gap-peer); border-radius: var(--radius-control); margin-left: var(--gap-peer);
8267 - vertical-align: middle; font-weight: bold;
8253 + color: var(--action); font-weight: bold;
8254 + margin-left: var(--gap-peer); vertical-align: middle;
8268 8255 }
8269 8256
8270 8257 /* ===========================================
@@ -8531,12 +8518,16 @@
8531 8518 font-size: var(--text-lead);
8532 8519 }
8533 8520
8521 + /* Founder pricing, two states, and neither is a status tone: locked-for-life
8522 + is emphasis and pending-lock-in is an aside. They were a --content fill and
8523 + a --surface-sunken fill back when every badge was filled; as coloured text
8524 + the same distinction is full-strength ink against muted. */
8534 8525 .badge--founder-locked {
8535 - background: var(--content);
8536 - color: var(--surface-overlay);
8526 + color: var(--content);
8527 + font-weight: bold;
8537 8528 }
8538 8529 .badge--founder-pending {
8539 - background: var(--surface-sunken);
8530 + color: var(--content-muted);
8540 8531 }
8541 8532
8542 8533 .founder-callout {
@@ -10926,12 +10917,9 @@
10926 10917 partials/admin_appeal_entries.html)
10927 10918 =========================================== */
10928 10919
10929 - /* Status badges built ad hoc (Active / Trusted). */
10930 - .badge-active,
10931 - .badge-trusted {
10932 - background: color-mix(in oklch, var(--success) 12%, var(--surface-page));
10933 - color: var(--success);
10934 - }
10920 + /* (`.badge-active` / `.badge-trusted` removed in wave 2 tier B2. They were a
10921 + second, ad-hoc spelling of what data-tone="success" already says, and the
10922 + admin entries take the tone now.) */
10935 10923
10936 10924 /* "Trust" toggle button sits next to a Trusted badge. */
10937 10925 .admin-entries-trust-btn {
@@ -266,7 +266,7 @@
266 266 pub source: String,
267 267 pub status: String,
268 268 /// Badge class for `status`, from `ImportJobStatus::badge_status`.
269 - pub status_class: &'static str,
269 + pub status_tone: &'static str,
270 270 pub total_rows: i32,
271 271 pub created_rows: i32,
272 272 pub created_at: chrono::DateTime<chrono::Utc>,
@@ -14,7 +14,7 @@
14 14 pub pitch: Option<String>,
15 15 pub status: String,
16 16 /// Badge class for `status`, from `WaitlistStatus::badge_status`.
17 - pub status_class: &'static str,
17 + pub status_tone: &'static str,
18 18 pub selection_method: Option<String>,
19 19 pub admin_note: Option<String>,
20 20 pub created_at: String,
@@ -8,9 +8,16 @@
8 8 //! attribute (`class="badge {{ tx.status|lowercase }}"`), which made the
9 9 //! stylesheet mirror whatever the schema spelled: a column rename silently
10 10 //! unstyled the badge, and a status nobody had written a rule for rendered
11 - //! plain. Every status-bearing view model now carries the class it should
11 + //! plain. Every status-bearing view model now carries the tone it should
12 12 //! render with, mapped from its enum by an exhaustive match, so a new variant
13 13 //! is a compile error instead of an unstyled badge.
14 + //!
15 + //! The tone is a `data-tone` attribute rather than a `.badge--*` class,
16 + //! because that is how makeover names it: `layout.css` carries
17 + //! `.badge[data-tone="success"]` and friends, and the four tone names are the
18 + //! design system's, not MNW's. Wave 2 tier B2 dropped the local `.badge--*`
19 + //! set, which was three fill colours restating what the generated sheet
20 + //! already says with colour.
14 21
15 22 /// One of the three states a badge can report, plus the neutral case.
16 23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -23,7 +30,7 @@
23 30 Failed,
24 31 /// Over, and nothing is wrong. Refunded, canceled.
25 32 ///
26 - /// This is the plain `.badge` with no modifier, which already reads as
33 + /// This is the plain `.badge` with no tone, which already reads as
27 34 /// neutral, so the vocabulary stays three colours wide. A terminal state
28 35 /// that nobody needs to act on is not a failure, and colouring it red
29 36 /// would ask for an action that does not exist.
@@ -31,12 +38,20 @@
31 38 }
32 39
33 40 impl BadgeStatus {
34 - /// The badge modifier class, or `""` for the neutral case.
35 - pub fn css_class(self) -> &'static str {
41 + /// The makeover tone name for `data-tone`, or `""` for the neutral case.
42 + ///
43 + /// An empty value is deliberate rather than a special case at the call
44 + /// site: `data-tone=""` matches none of the generated attribute
45 + /// selectors, so the badge takes the plain `.badge` colour. That keeps
46 + /// every template a single unconditional attribute.
47 + ///
48 + /// makeover's fourth tone, `info`, has no status in this set that means
49 + /// it. Live, Pending and Failed cover what MNW reports.
50 + pub fn tone(self) -> &'static str {
36 51 match self {
37 - Self::Live => "badge--live",
38 - Self::Pending => "badge--pending",
39 - Self::Failed => "badge--failed",
52 + Self::Live => "success",
53 + Self::Pending => "warning",
54 + Self::Failed => "danger",
40 55 Self::Ended => "",
41 56 }
42 57 }
@@ -47,21 +62,24 @@
47 62 use super::*;
48 63 use crate::db::{ImportJobStatus, SubscriptionStatus, TransactionStatus, WaitlistStatus};
49 64
50 - /// The classes this type emits are the ones the stylesheet defines. If a
51 - /// variant is added here without a rule in `static/style.css`, the badge
52 - /// renders plain and nobody notices, which is the bug this type exists to
53 - /// prevent.
65 + /// The tones this type emits are the ones the generated sheet defines. If
66 + /// a variant is added here without a rule in `static/layout.css`, the
67 + /// badge renders plain and nobody notices, which is the bug this type
68 + /// exists to prevent. Reading the GENERATED sheet is the point: the tone
69 + /// vocabulary belongs to makeover, so this test fails if a regeneration
70 + /// ever drops one rather than passing against a local rule we wrote to
71 + /// match.
54 72 #[test]
55 - fn every_class_is_defined_in_the_stylesheet() {
56 - let css = include_str!("../../static/style.css");
73 + fn every_tone_is_defined_in_the_generated_sheet() {
74 + let css = include_str!("../../static/layout.css");
57 75 for status in [BadgeStatus::Live, BadgeStatus::Pending, BadgeStatus::Failed] {
58 - let class = status.css_class();
76 + let tone = status.tone();
59 77 assert!(
60 - css.contains(&format!(".{class} {{")),
61 - "{class} has no rule in static/style.css"
78 + css.contains(&format!(r#".badge[data-tone="{tone}"]"#)),
79 + "tone {tone} has no rule in static/layout.css"
62 80 );
63 81 }
64 - assert_eq!(BadgeStatus::Ended.css_class(), "");
82 + assert_eq!(BadgeStatus::Ended.tone(), "");
65 83 }
66 84
67 85 /// Every status enum that reaches a badge maps into the set. Exhaustive
@@ -16,6 +16,6 @@
16 16 pub slug: String,
17 17 pub status: String,
18 18 /// Badge class for `status`, picked beside the label.
19 - pub status_class: &'static str,
19 + pub status_tone: &'static str,
20 20 pub published_at: String,
21 21 }
@@ -234,7 +234,7 @@
234 234 pub revenue: String,
235 235 pub status: String,
236 236 /// Badge class for `status`, picked beside the label in `from_db`.
237 - pub status_class: &'static str,
237 + pub status_tone: &'static str,
238 238 pub id: String,
239 239 /// True if this item is unlisted (only accessible via bundle).
240 240 pub is_unlisted: bool,
@@ -198,7 +198,7 @@
198 198 email_verified: e.email_verified,
199 199 pitch: e.pitch.clone(),
200 200 status: e.status.to_string(),
201 - status_class: e.status.badge_status().css_class(),
201 + status_tone: e.status.badge_status().tone(),
202 202 selection_method: e.selection_method.map(|m| m.to_string()),
203 203 admin_note: e.admin_note.clone(),
204 204 created_at: e.created_at.format(DATE_FMT_FULL).to_string(),
@@ -331,7 +331,7 @@
331 331 tier_name: s.tier_name.clone(),
332 332 price: format!("{}/mo", format_price(s.price_cents, s.settlement_currency)),
333 333 status: s.status.to_string(),
334 - status_class: s.status.badge_status().css_class(),
334 + status_tone: s.status.badge_status().tone(),
335 335 current_period_end: s
336 336 .current_period_end
337 337 .map(|d| d.format(DATE_FMT_FULL).to_string()),
@@ -522,7 +522,7 @@
522 522 ),
523 523 is_incoming: true,
524 524 status: tx.status.to_string(),
525 - status_class: tx.status.badge_status().css_class(),
525 + status_tone: tx.status.badge_status().tone(),
526 526 details: format!(
527 527 "Fee: {}",
528 528 format_price(tx.platform_fee_cents, tx.currency())
@@ -539,7 +539,7 @@
539 539 amount: format!("-{}", format_price(tx.amount_cents, tx.currency())),
540 540 is_incoming: false,
541 541 status: tx.status.to_string(),
542 - status_class: tx.status.badge_status().css_class(),
542 + status_tone: tx.status.badge_status().tone(),
543 543 details: String::new(),
544 544 }
545 545 }
@@ -574,7 +574,7 @@
574 574 /// the public/draft status label.
575 575 impl ContentItem {
576 576 pub fn from_db(item: &db::DbItem, position: u32, currency: SettlementCurrency) -> Self {
577 - let (status, status_class) = if item.is_public {
577 + let (status, status_tone) = if item.is_public {
578 578 ("Active", crate::types::BadgeStatus::Live)
579 579 } else if item.publish_at.is_some() {
580 580 ("Scheduled", crate::types::BadgeStatus::Pending)
@@ -591,7 +591,7 @@
591 591 sales: 0,
592 592 revenue: "$0".to_string(),
593 593 status: status.to_string(),
594 - status_class: status_class.css_class(),
594 + status_tone: status_tone.tone(),
595 595 id: item.id.to_string(),
596 596 is_unlisted: !item.listed,
597 597 children: Vec::new(),
@@ -843,10 +843,10 @@
843 843 } else {
844 844 "Draft".to_string()
845 845 },
846 - status_class: if p.is_public {
847 - crate::types::BadgeStatus::Live.css_class()
846 + status_tone: if p.is_public {
847 + crate::types::BadgeStatus::Live.tone()
848 848 } else {
849 - crate::types::BadgeStatus::Pending.css_class()
849 + crate::types::BadgeStatus::Pending.tone()
850 850 },
851 851 slug: p.slug.to_string(),
852 852 }