Skip to main content

max / makenotwork

Make the canonical card and link names the ones templates actually write
Author: Max Johnson <me@maxj.phd> · 2026-08-02 21:50 UTC
Signed with PGP, not checked
Commit: 9a2388853a7a6c72d6177d6132b0c98bd881239d
Parent: a5c24ff
25 files changed, +92 insertions, -157 deletions
@@ -83,7 +83,7 @@
83 83 | Primitive | Canonical class | Variants | States |
84 84 |---|---|---|---|
85 85 | Page section box | `.content-section` | (none) | — |
86 - | Card | `.card` | `.card-muted` (surface-muted fill), `.card--bordered`, `.card--selectable` | `:hover`, `:focus-within`, `.is-selected` |
86 + | Card | `.card` | `.card-muted` (surface-muted fill), `.card--bordered`, `.card--selectable` (+ `.is-text-heavy` for left-aligned content) | `:hover`, `:focus-within`, `.is-selected`, `.is-disabled` |
87 87 | Button | `.btn-primary` / `.btn-secondary` / `.btn-danger` | `.btn--large`, `.btn--icon`, `.btn--link`, `.small`, `.saved` | `:hover`, `:focus-visible`, `:disabled`, `.htmx-request` |
88 88 | Heading | `.brand-h1` (wordmark), `.page-title` (h1), `.subtitle-h2` (auth/wizard h2), `.subsection-title` (default h2), `.section-header` (h2 with bottom border) | — | — |
89 89 | Input shape | (base `input`/`textarea`/`select`) | `.input--xs`, `.input--sm`, `.input--mono`, `.input--upper`, `.input--numeric` | `:focus`, `:disabled` |
@@ -122,6 +122,8 @@
122 122
123 123 A new class is a smell, not a goal. Three usages without an entry above means a missing primitive, not a license to keep inlining.
124 124
125 + **No aliases.** A shared recipe is reached by writing its canonical name in the markup, never by adding a page-specific name to the recipe's selector list. A component that also needs rules of its own keeps its own class and puts the canonical modifier beside it: `class="card--bordered tier-card"`. The alias habit is what let `.card--selectable` and `.card--bordered` sit in this table for months while zero templates used either name, so a developer following the charter wrote a class that matched nothing else in the codebase.
126 +
125 127 Page-scoped CSS (`.foo-page .bar`) is a last resort. Most page-scoped rules in `style.css` exist for genuine page-specific layout (e.g. `.item-page .item-layout` grid, `.article-page .article-body` typography). Don't add new ones for shapes that are really cards or list rows in disguise.
126 128
127 129 ### How to use the macros
@@ -1105,9 +1105,9 @@
1105 1105 .card.card--bordered bordered, padded, no fill (marketing cards: feature/tier/use-case).
1106 1106 .card.card--selectable radio-card pattern; pair with .is-selected.
1107 1107 .card.card--grid grid-cell card (discover grid).
1108 - Legacy class names (.stat-card, .analytics-card, .feature-card, .tier-card,
1109 - .use-case-card) are aliased to these recipes below, templates migrate at
1110 - their own pace. */
1108 + No aliases. A template names the modifier it wants and adds its own
1109 + component class beside it; a recipe reached only through a per-page name is
1110 + a recipe nobody can find from the charter. */
1111 1111 .card {
1112 1112 background: var(--surface-overlay);
1113 1113 padding: var(--gap-pane);
@@ -1129,12 +1129,10 @@
1129 1129 background: var(--surface-sunken);
1130 1130 padding: var(--gap-pane);
1131 1131 }
1132 - /* Bordered card (.card--bordered alias for .feature-card, .tier-card, .use-case-card, .fork-card). */
1133 - .card--bordered,
1134 - .feature-card,
1135 - .tier-card,
1136 - .use-case-card,
1137 - .fork-card {
1132 + /* Bordered card. Templates put this modifier on the element alongside any
1133 + component class of their own (.tier-card, .use-case-card, .fork-card), so
1134 + the name the charter teaches is the name in the markup. */
1135 + .card--bordered {
1138 1136 background: transparent;
1139 1137 padding: var(--gap-section);
1140 1138 border: 1px solid var(--border);
@@ -1791,7 +1789,6 @@
1791 1789 font-weight: bold;
1792 1790 }
1793 1791
1794 - .project-page .item-title a { color: inherit; text-decoration: none; }
1795 1792 .project-page .item-title a:hover { text-decoration: underline; }
1796 1793
1797 1794 .project-page .item-meta {
@@ -2078,10 +2075,6 @@
2078 2075 color: var(--content);
2079 2076 }
2080 2077
2081 - .article-page .author-name a {
2082 - color: inherit;
2083 - text-decoration: none;
2084 - }
2085 2078
2086 2079 .article-page .author-name a:hover { text-decoration: underline; }
2087 2080
@@ -2234,10 +2227,6 @@
2234 2227 margin-bottom: var(--gap-peer);
2235 2228 }
2236 2229
2237 - .article-page .author-bio-name a {
2238 - color: inherit;
2239 - text-decoration: none;
2240 - }
2241 2230
2242 2231 .article-page .author-bio-name a:hover { text-decoration: underline; }
2243 2232
@@ -3536,7 +3525,6 @@
3536 3525 margin-bottom: var(--gap-pane);
3537 3526 opacity: 0.7;
3538 3527 }
3539 - .tag-tree-page .breadcrumbs a { text-decoration: none; color: inherit; }
3540 3528 .tag-tree-page .breadcrumbs a:hover { opacity: 0.6; }
3541 3529 .tag-tree-page .breadcrumbs .sep { margin: 0 var(--gap-bound); opacity: 0.4; }
3542 3530
@@ -3736,7 +3724,6 @@
3736 3724
3737 3725 .project-blog-page .post-entry:first-child { padding-top: 0; }
3738 3726 .project-blog-page .post-title { font-size: var(--text-head); margin-bottom: var(--gap-peer); }
3739 - .project-blog-page .post-title a { color: inherit; text-decoration: none; }
3740 3727 .project-blog-page .post-title a:hover { text-decoration: underline; }
3741 3728
3742 3729 .project-blog-page .post-date {
@@ -7634,27 +7621,18 @@
7634 7621 /* Canonical selectable-card primitive. One recipe; per-context size modifiers.
7635 7622 Used by wizard type pickers, pricing-model pickers, content-choice pickers,
7636 7623 monetization-mode pickers, anywhere a radio-like choice is rendered as a card. */
7637 - .card--selectable,
7638 - .type-card,
7639 - .pricing-card,
7640 - .content-choice-card {
7624 + .card--selectable {
7641 7625 cursor: pointer;
7642 7626 display: block;
7643 7627 }
7644 7628 .card--selectable input[type="radio"],
7645 - .card--selectable input[type="checkbox"],
7646 - .type-card input[type="radio"],
7647 - .type-card input[type="checkbox"],
7648 - .pricing-card input[type="radio"],
7649 - .content-choice-card input[type="radio"] {
7629 + .card--selectable input[type="checkbox"] {
7650 7630 position: absolute;
7651 7631 opacity: 0;
7652 7632 pointer-events: none;
7653 7633 }
7654 7634
7655 - .card--selectable-inner,
7656 - .type-card-inner,
7657 - .pricing-card-inner {
7635 + .card--selectable-inner {
7658 7636 display: flex;
7659 7637 flex-direction: column;
7660 7638 align-items: center;
@@ -7668,7 +7646,6 @@
7668 7646 min-height: 60px;
7669 7647 }
7670 7648 /* Variant: column-aligned content (text-heavy pricing cards). */
7671 - .pricing-card-inner,
7672 7649 .card--selectable.is-text-heavy .card--selectable-inner {
7673 7650 align-items: stretch;
7674 7651 text-align: left;
@@ -7676,48 +7653,28 @@
7676 7653 }
7677 7654
7678 7655 .card--selectable input:checked + .card--selectable-inner,
7679 - .card--selectable.is-selected .card--selectable-inner,
7680 - .type-card input:checked + .type-card-inner,
7681 - .type-card.is-selected .type-card-inner,
7682 - .pricing-card input:checked + .pricing-card-inner,
7683 - .pricing-card.is-selected .pricing-card-inner,
7684 - .content-choice-card.is-selected {
7656 + .card--selectable.is-selected .card--selectable-inner {
7685 7657 border-color: var(--action);
7686 7658 background: color-mix(in oklch, var(--action) 8%, transparent);
7687 7659 }
7688 7660
7689 - .card--selectable:hover .card--selectable-inner,
7690 - .type-card:hover .type-card-inner,
7691 - .pricing-card:hover .pricing-card-inner,
7692 - .content-choice-card:hover {
7661 + .card--selectable:hover .card--selectable-inner {
7693 7662 background: var(--surface-sunken);
7694 7663 }
7695 7664
7696 - .card--selectable-label,
7697 - .type-card-label,
7698 - .pricing-card-label {
7665 + .card--selectable-label {
7699 7666 font-weight: bold;
7700 7667 font-size: var(--text-note);
7701 7668 }
7702 7669
7703 - .card--selectable-desc,
7704 - .type-card-desc,
7705 - .pricing-card-desc {
7670 + .card--selectable-desc {
7706 7671 font-size: var(--text-fine);
7707 7672 color: var(--content-muted);
7708 7673 margin-top: var(--gap-bound);
7709 7674 }
7710 7675
7711 - /* Anchor-styled selectable card (used when the card itself is an `<a>` link
7712 - rather than a radio-wrapping label). */
7713 - .content-choice-card {
7714 - text-decoration: none;
7715 - color: inherit;
7716 - }
7717 -
7718 7676 /* Disabled selectable card, visually dimmed, ignores pointer events. */
7719 - .card--selectable.is-disabled,
7720 - .content-choice-card.is-disabled {
7677 + .card--selectable.is-disabled {
7721 7678 opacity: 0.7;
7722 7679 pointer-events: none;
7723 7680 }
@@ -9047,10 +9004,6 @@
9047 9004 display: block;
9048 9005 }
9049 9006
9050 - .item-page .tag-link {
9051 - text-decoration: none;
9052 - color: inherit;
9053 - }
9054 9007
9055 9008 .item-page .bundle-notice-inline {
9056 9009 background: var(--surface-sunken);
@@ -9429,10 +9382,6 @@
9429 9382 opacity: 0.7;
9430 9383 padding: var(--gap-peer) 0;
9431 9384 }
9432 - .project-page .item-tags .tag {
9433 - text-decoration: none;
9434 - color: inherit;
9435 - }
9436 9385 .project-page .promo-details {
9437 9386 font-size: var(--text-note);
9438 9387 margin-bottom: var(--gap-peer);
@@ -10157,10 +10106,6 @@
10157 10106 font-family: var(--font-mono);
10158 10107 color: var(--content-muted);
10159 10108 }
10160 - .video-tag-link {
10161 - text-decoration: none;
10162 - color: inherit;
10163 - }
10164 10109
10165 10110 /* ===========================================
10166 10111 LIBRARY LOCKED PAGE (templates/pages/library_locked.html)
@@ -10270,10 +10215,6 @@
10270 10215 font-family: var(--font-mono);
10271 10216 color: var(--content-muted);
10272 10217 }
10273 - .audio-tag-link {
10274 - text-decoration: none;
10275 - color: inherit;
10276 - }
10277 10218
10278 10219 /* ===========================================
10279 10220 SECTIONS WIZARD STEP (templates/wizards/steps/item/sections.html)
@@ -10364,13 +10305,6 @@
10364 10305 color: var(--content-muted);
10365 10306 }
10366 10307
10367 - /* Plain (text-decoration-free, inherit-colored) tag link on text_reader.
10368 - Distinguishes from the default linked .article-tag in library_text. */
10369 - .article-page .article-tag--plain {
10370 - text-decoration: none;
10371 - color: inherit;
10372 - }
10373 -
10374 10308 /* Library back-link breadcrumb shared by the media library pages. */
10375 10309 .media-container .library-back {
10376 10310 font-size: var(--text-note);
@@ -10614,7 +10548,6 @@
10614 10548 .key-code-small:hover { background: var(--border); }
10615 10549
10616 10550 /* Library row title link reads as plain text until hovered. */
10617 - .library-title-link { color: inherit; text-decoration: none; }
10618 10551 .library-title-link:hover { text-decoration: underline; }
10619 10552 /* ===========================================
10620 10553 ADMIN ENTRIES (replaces inline styles in partials/admin_user_entries.html,
@@ -141,8 +141,8 @@
141 141 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
142 142 }
143 143
144 - /* (.type-card primitive lives in style.css under the .card--selectable
145 - canonical recipe; this file only contains wizard-specific layouts.) */
144 + /* (The cards themselves are .card--selectable, defined in style.css; this
145 + file only contains wizard-specific layouts.) */
146 146
147 147 /* Suggestion Input (category, tags) */
148 148
@@ -217,8 +217,8 @@
217 217 margin-bottom: var(--gap-pane);
218 218 }
219 219
220 - /* (`.pricing-card`, `.content-choice-card`, and the `.is-disabled` modifier
221 - all live in style.css under the canonical `.card--selectable` recipe.) */
220 + /* (The cards are `.card--selectable`, the text-heavy pricing variant adds
221 + `.is-text-heavy`, and `.is-disabled` is its modifier. All in style.css.) */
222 222 .pricing-fields {
223 223 margin-top: var(--gap-section);
224 224 }
@@ -120,7 +120,7 @@
120 120 {% if !item.tags.is_empty() %}
121 121 <div class="media-tags">
122 122 {% for tag in item.tags %}
123 - <a href="/discover?tag={{ tag.slug }}" class="tag audio-tag-link">{{ tag.name }}</a>
123 + <a href="/discover?tag={{ tag.slug }}" class="tag unstyled-link">{{ tag.name }}</a>
124 124 {% endfor %}
125 125 </div>
126 126 {% endif %}
@@ -54,7 +54,7 @@
54 54 <header class="author-header">
55 55 <div class="author-avatar">{{ creator_avatar_initials }}</div>
56 56 <div class="author-info">
57 - <div class="author-name"><a href="/u/{{ creator_username }}">{% if let Some(name) = creator_display_name %}{{ name }}{% else %}{{ creator_username }}{% endif %}</a></div>
57 + <div class="author-name"><a href="/u/{{ creator_username }}" class="unstyled-link">{% if let Some(name) = creator_display_name %}{{ name }}{% else %}{{ creator_username }}{% endif %}</a></div>
58 58 <div class="article-meta">{{ published_at }}</div>
59 59 </div>
60 60 </header>
@@ -77,7 +77,7 @@
77 77 {% endif %}
78 78
79 79 <div class="landing-fork">
80 - <div class="fork-card{% if founder_window_open %} fork-card--founder{% endif %}">
80 + <div class="card--bordered fork-card{% if founder_window_open %} fork-card--founder{% endif %}">
81 81 <h2 class="fork-heading">For creators</h2>
82 82
83 83 {% if founder_window_open %}
@@ -123,7 +123,7 @@
123 123 </div>
124 124 </div>
125 125
126 - <div class="fork-card">
126 + <div class="card--bordered fork-card">
127 127 <h2 class="fork-heading">Fan+</h2>
128 128 <p class="fork-lede">Support the platform directly and get something back every month.</p>
129 129 <ul class="fork-list">
@@ -121,7 +121,7 @@
121 121
122 122 <div class="item-tags">
123 123 {% for tag in item.tags %}
124 - <a href="/discover?tag={{ tag.slug }}" class="tag tag-link{% if tag.is_primary %} tag-primary{% endif %}">{{ tag.name }}</a>
124 + <a href="/discover?tag={{ tag.slug }}" class="tag unstyled-link{% if tag.is_primary %} tag-primary{% endif %}">{{ tag.name }}</a>
125 125 {% endfor %}
126 126 </div>
127 127
@@ -19,7 +19,7 @@
19 19 <header class="author-header">
20 20 <div class="author-avatar">{{ creator_avatar_initials }}</div>
21 21 <div class="author-info">
22 - <div class="author-name"><a href="/u/{{ creator_username }}">{% if let Some(name) = creator_display_name %}{{ name }}{% else %}{{ creator_username }}{% endif %}</a></div>
22 + <div class="author-name"><a href="/u/{{ creator_username }}" class="unstyled-link">{% if let Some(name) = creator_display_name %}{{ name }}{% else %}{{ creator_username }}{% endif %}</a></div>
23 23 <div class="article-meta">{{ item.release_date }}{% if let Some(rt) = reading_time %} | {{ rt }}{% endif %}</div>
24 24 </div>
25 25 </header>
@@ -34,25 +34,25 @@
34 34 <h2 class="section-label">Your content tier</h2>
35 35 <p class="tier-intro">Every tier is the complete platform: profile, project pages, forum, discovery, memberships, analytics, full data export. The tier picks the file-size envelope, not the feature set.</p>
36 36 <div class="tier-selector" role="radiogroup" aria-label="Content tier">
37 - <label class="tier-card tier-option">
37 + <label class="card--bordered tier-card tier-option">
38 38 <input type="radio" name="tier" value="{{ tier_prices.basic_std }}" checked>
39 39 <div class="tier-name">Basic</div>
40 40 <div class="tier-price">${{ tier_prices.basic_std }}/mo</div>
41 41 <div class="tier-desc">{{ tier_prices.basic_per_file }}/file, {{ tier_prices.basic_total }} total. Fits text, blogs, newsletters.</div>
42 42 </label>
43 - <label class="tier-card tier-option">
43 + <label class="card--bordered tier-card tier-option">
44 44 <input type="radio" name="tier" value="{{ tier_prices.small_files_std }}">
45 45 <div class="tier-name">Small Files</div>
46 46 <div class="tier-price">${{ tier_prices.small_files_std }}/mo</div>
47 47 <div class="tier-desc">{{ tier_prices.small_files_per_file }}/file, {{ tier_prices.small_files_total }} total. Fits audio, plugins, binaries.</div>
48 48 </label>
49 - <label class="tier-card tier-option">
49 + <label class="card--bordered tier-card tier-option">
50 50 <input type="radio" name="tier" value="{{ tier_prices.big_files_std }}">
51 51 <div class="tier-name">Big Files</div>
52 52 <div class="tier-price">${{ tier_prices.big_files_std }}/mo</div>
53 53 <div class="tier-desc">{{ tier_prices.big_files_per_file }}/file, {{ tier_prices.big_files_total }} total. Fits video, games, large software.</div>
54 54 </label>
55 - <label class="tier-card tier-option">
55 + <label class="card--bordered tier-card tier-option">
56 56 <input type="radio" name="tier" value="{{ tier_prices.everything_std }}">
57 57 <div class="tier-name">Everything</div>
58 58 <div class="tier-price">${{ tier_prices.everything_std }}/mo</div>
@@ -129,11 +129,11 @@
129 129 <div class="item-card">
130 130 <a href="{% if item.can_access %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}" class="item-thumbnail">{{ item.thumbnail }}</a>
131 131 <div class="item-content">
132 - <h3 class="item-title"><a href="{% if item.can_access %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}">{{ item.title }}</a></h3>
132 + <h3 class="item-title"><a class="unstyled-link" href="{% if item.can_access %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}">{{ item.title }}</a></h3>
133 133 <div class="item-meta">{{ item.item_type }}{% if item.bundle_item_count > 0 %} ({{ item.bundle_item_count }} items){% endif %} &middot; {{ item.release_date }}</div>
134 134 <div class="item-tags">
135 135 {% for tag in item.tags %}
136 - <a href="/discover?tag={{ tag.slug }}" class="tag">{{ tag.name }}</a>
136 + <a href="/discover?tag={{ tag.slug }}" class="tag unstyled-link">{{ tag.name }}</a>
137 137 {% endfor %}
138 138 </div>
139 139 <p class="item-description">{{ item.description }}</p>
@@ -177,7 +177,7 @@
177 177 <h2 class="section-header">Membership</h2>
178 178 <div class="tiers-grid">
179 179 {% for tier in subscription_tiers %}
180 - <div class="tier-card">
180 + <div class="card--bordered tier-card">
181 181 <div class="tier-name">{{ tier.name }}</div>
182 182 <div class="tier-price">{{ tier.price }}</div>
183 183 {% if !tier.description.is_empty() %}
@@ -29,7 +29,7 @@
29 29 {% else %}
30 30 {% for post in posts %}
31 31 <div class="post-entry">
32 - <h2 class="post-title"><a href="/p/{{ project_slug }}/blog/{{ post.slug }}">{{ post.title }}</a></h2>
32 + <h2 class="post-title"><a class="unstyled-link" href="/p/{{ project_slug }}/blog/{{ post.slug }}">{{ post.title }}</a></h2>
33 33 <div class="post-date">{{ post.published_at }}</div>
34 34 </div>
35 35 {% endfor %}
@@ -66,7 +66,7 @@
66 66 {% if !subscription_tiers.is_empty() %}
67 67 <div class="paywall-tiers">
68 68 {% for tier in subscription_tiers %}
69 - <div class="tier-card">
69 + <div class="card--bordered tier-card">
70 70 <h3>{{ tier.name }}</h3>
71 71 <div class="tier-price">{{ tier.price }}/mo</div>
72 72 {% if !tier.description.is_empty() %}
@@ -13,10 +13,10 @@
13 13 <h1 class="page-title">Browse Tags</h1>
14 14
15 15 <nav class="breadcrumbs" aria-label="Tag breadcrumbs">
16 - <a href="/discover/tags">All Tags</a>
16 + <a href="/discover/tags" class="unstyled-link">All Tags</a>
17 17 {% for bc in breadcrumbs %}
18 18 <span class="sep">/</span>
19 - <a href="/discover/tags?parent={{ bc.slug }}">{{ bc.name }}</a>
19 + <a href="/discover/tags?parent={{ bc.slug }}" class="unstyled-link">{{ bc.name }}</a>
20 20 {% endfor %}
21 21 {% if let Some(ct) = current_tag %}
22 22 <span class="sep">/</span>