Skip to main content

max / makenotwork

Close the four charter-vs-stylesheet gaps in Platinum phase 5 Press: the four remaining `:active` rules that flattened or swapped a background now invert the bevel like every other control, so the charter's claim of one spelling per state holds for `.btn--link`, the menu and disclosure openers, and `.order-btn`. Badges: a badge names the status it reports and takes its colour from that status. `.badge--live` / `--pending` / `--failed` replace `.badge-success`, `.badge.active` and `.badge.complete`; the literal template sites move onto the set. The raw-status classes stay behind a comment naming GoingsOn 8a007e42, which replaces the class-attribute interpolation that writes them. `--highlight-faint` becomes a real token holding the faint accent tint the charter already names, and the ten rules that inlined the color-mix read it. Focus: `.card--selectable` and the sort headers gain the focus-visible outline. The headers were click-only, so they also take `tabindex="0"`, and the `data-action` dispatcher now activates a focusable non-native element on Enter and Space.
Author: Max Johnson <me@maxj.phd> · 2026-08-02 23:24 UTC
Signed with PGP, not checked
Commit: 2e2d18a7f8dd72245217183a16e6d42f4bf9ef06
Parent: 513f3f8
13 files changed, +101 insertions, -56 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` | `.badge-success`, `.free`, plus state classes `.pending` / `.draft` / `.failed` | `.is-selected`, `.is-faded`, `.active` (= completion, not selection) |
95 + | Badge | `.badge` | status set `.badge--live` / `.badge--pending` / `.badge--failed`; `.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` | — |
@@ -150,11 +150,12 @@
150 150 Exactly one spelling for each interaction state, applied to every interactive primitive:
151 151
152 152 - **Hover**: fill shift to `var(--hover-surface)`, on every interactive primitive. One spelling, no component-type branch. The exception is `.btn--link`, which is semantically a link and has no surface to shift: it fades to opacity `0.6`.
153 - - **Press**: `:active` inverts the bevel, `box-shadow: var(--shadow-inset)`. Nothing moves; there is no `transform: translate(2px, 2px)` and no `box-shadow: none`.
153 + - **Press**: `:active` inverts the bevel, `box-shadow: var(--shadow-inset)`. Nothing moves; there is no `transform: translate(2px, 2px)`, and no control opts out. A flat control inverts on press the same as a raised one, including `.btn--link` and the menu and disclosure openers.
154 154
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 - - **Focus**: `:focus-visible` shows the `--focus-ring` violet outline. Custom interactive containers (`.type-card`, `.pricing-card`, sort headers) must opt in by adding `:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }`.
157 - - **Selected / active**: `.is-selected` modifier applies `background: var(--highlight-faint)` plus the focus-ring border. Migrate `.tab.active`, `.filter-item.active`, `.view-btn.active`, `.badge.active`, and the `:checked + .type-card-inner` recipe onto this single class.
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 + - **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 159 - **Disabled**: `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`.
159 160 - **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.
160 161
@@ -187,8 +187,8 @@
187 187 APP-LOCAL CONSTANTS, not theme colors, not derivable from intents.
188 188
189 189 Component CSS references the intent tokens above directly (no brand
190 - aliases). Status backgrounds and the faint accent tint derive from the
191 - intents inline via color-mix(); these few stay fixed:
190 + aliases). Status backgrounds derive from the intents inline via
191 + color-mix(); these few stay fixed:
192 192 - pure black/white contrast anchors
193 193 - Stripe's brand color (an external constant)
194 194 - diff add/del, a hard readability convention that must not re-theme
@@ -197,6 +197,11 @@
197 197 --primary-light: #ffffff;
198 198 --stripe: #635BFF;
199 199
200 + /* The faint accent tint every selection and highlight recipe fills with.
201 + Derived from --action, so it re-tints with the creator's theme. Named
202 + because the charter names it and because a dozen rules read it. */
203 + --highlight-faint: color-mix(in oklch, var(--action) 8%, transparent);
204 +
200 205 /* Diff / code review, fixed green-add / red-del convention */
201 206 --diff-add: #2d7d2d;
202 207 --diff-add-bg: rgba(45, 125, 45, 0.08);
@@ -558,14 +563,12 @@
558 563 }
559 564
560 565 .btn--link:active {
561 - box-shadow: none;
562 - transform: none;
566 + box-shadow: var(--shadow-inset);
563 567 }
564 568
565 569 /* Menu / disclosure openers, buttons whose job is to reveal a menu,
566 - filter panel, expander, or popover. They navigate UI, they don't
567 - commit anything, so they override the base `button` depth-lift back
568 - to flat. Add new opener selectors here as they appear. */
570 + filter panel, expander, or popover. They sit flat rather than raised.
571 + Add new opener selectors here as they appear. */
569 572 .context-menu-btn,
570 573 .discover-filter-toggle,
571 574 .tip-toggle,
@@ -584,8 +587,7 @@
584 587 .discover-filter-toggle:active,
585 588 .tip-toggle:active,
586 589 .bundle-toggle:active {
587 - box-shadow: none;
588 - transform: none;
590 + box-shadow: var(--shadow-inset);
589 591 }
590 592
591 593 /* ===========================================
@@ -857,6 +859,11 @@
857 859 background: var(--border);
858 860 }
859 861
862 + .data-table th.sortable:focus-visible {
863 + outline: 2px solid var(--focus-ring);
864 + outline-offset: 2px;
865 + }
866 +
860 867 .data-table th.sortable::after {
861 868 content: " ^";
862 869 opacity: 0.3;
@@ -998,32 +1005,39 @@
998 1005 color: var(--primary-light);
999 1006 }
1000 1007
1001 - .badge-success,
1002 - .badge.active,
1003 - .badge.complete {
1008 + /* Status vocabulary (charter: docs/design-system.md). A badge names the status
1009 + it reports, and the colour follows from the status. */
1010 + .badge--live {
1004 1011 background: var(--success);
1005 1012 }
1006 1013
1007 - .badge.pending,
1008 - .badge.draft {
1014 + .badge--pending {
1009 1015 background: var(--warning);
1010 1016 }
1011 1017
1012 - .badge.failed {
1018 + .badge--failed {
1013 1019 background: var(--danger);
1014 1020 }
1015 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,
1016 1028 .badge.current {
1017 1029 background: var(--success);
1018 1030 }
1019 1031
1032 + .badge.pending,
1033 + .badge.draft,
1020 1034 .badge.suppressed {
1021 1035 background: var(--warning);
1022 1036 }
1023 1037
1038 + .badge.failed,
1024 1039 .badge.suspended {
1025 - background: color-mix(in oklch, var(--danger) 10%, var(--surface-page));
1026 - color: var(--danger);
1040 + background: var(--danger);
1027 1041 }
1028 1042
1029 1043 .badge.free {
@@ -4315,7 +4329,7 @@
4315 4329 }
4316 4330
4317 4331 .banner--info {
4318 - background: color-mix(in oklch, var(--action) 8%, transparent);
4332 + background: var(--highlight-faint);
4319 4333 border-bottom: 2px solid var(--focus-ring);
4320 4334 }
4321 4335
@@ -5091,12 +5105,13 @@
5091 5105
5092 5106 /* Canonical "selected" recipe (charter: docs/design-system.md).
5093 5107 Apply `.is-selected` to any interactive container to mark it as the
5094 - currently-selected option. `.badge.active` is intentionally distinct.
5095 - It expresses completion status, not selection. Visibility toggles
5096 - (`.tab-content.active`, `.section-panel.active`, `.editor-panel.active`)
5097 - also keep `.active` since they're not selection state. */
5108 + currently-selected option. A badge never takes it: a badge reports a status
5109 + and nobody selects "Enabled", so badges use the `.badge--*` status set.
5110 + Visibility toggles (`.tab-content.active`, `.section-panel.active`,
5111 + `.editor-panel.active`) also keep `.active` since they're not selection
5112 + state. */
5098 5113 .is-selected {
5099 - background: color-mix(in oklch, var(--action) 8%, transparent);
5114 + background: var(--highlight-faint);
5100 5115 border-color: var(--focus-ring);
5101 5116 }
5102 5117
@@ -5266,8 +5281,7 @@
5266 5281 }
5267 5282
5268 5283 .order-btn:active {
5269 - background: var(--primary-dark);
5270 - color: var(--primary-light);
5284 + box-shadow: var(--shadow-inset);
5271 5285 }
5272 5286
5273 5287 /* ===========================================
@@ -5870,7 +5884,7 @@
5870 5884
5871 5885 .fork-card--founder {
5872 5886 border-color: var(--action);
5873 - box-shadow: 0 0 0 1px color-mix(in oklch, var(--action) 8%, transparent);
5887 + box-shadow: 0 0 0 1px var(--highlight-faint);
5874 5888 }
5875 5889
5876 5890 .founder-tier-grid {
@@ -7066,7 +7080,7 @@
7066 7080 }
7067 7081 .git-diff-hunk-header td {
7068 7082 padding: var(--gap-bound) var(--gap-peer);
7069 - background: color-mix(in oklch, var(--action) 8%, transparent);
7083 + background: var(--highlight-faint);
7070 7084 color: var(--content);
7071 7085 opacity: 0.6;
7072 7086 font-size: var(--text-fine);
@@ -7106,7 +7120,7 @@
7106 7120 }
7107 7121 .git-file-content .line-number a:hover { opacity: 0.7; }
7108 7122 .git-file-content tr:target td,
7109 - .git-file-content tr.highlighted td { background: color-mix(in oklch, var(--action) 8%, transparent); }
7123 + .git-file-content tr.highlighted td { background: var(--highlight-faint); }
7110 7124
7111 7125 /* Git blame view */
7112 7126 .git-blame-content { overflow-x: auto; border: 1px solid var(--border); }
@@ -7453,7 +7467,7 @@
7453 7467 .filter-item .count { opacity: 0.4; font-size: var(--text-fine); }
7454 7468 .tag-follow-btn { background: none; border: 1px solid var(--border); cursor: pointer; font-family: var(--font-mono); font-size: var(--text-fine); opacity: 0.55; padding: var(--gap-bound) var(--gap-peer); color: var(--content); }
7455 7469 .tag-follow-btn:hover { opacity: 1; }
7456 - .tag-follow-btn.is-selected { opacity: 0.85; border-color: var(--focus-ring); background: color-mix(in oklch, var(--action) 8%, transparent); }
7470 + .tag-follow-btn.is-selected { opacity: 0.85; border-color: var(--focus-ring); background: var(--highlight-faint); }
7457 7471 .filter-checkbox { display: flex; align-items: center; gap: var(--gap-peer); padding: var(--gap-bound) 0; cursor: pointer; }
7458 7472 .filter-checkbox input { width: auto; margin: 0; }
7459 7473 .price-inputs { display: flex; gap: var(--gap-bound); align-items: center; margin-bottom: var(--gap-peer); }
@@ -7472,7 +7486,7 @@
7472 7486 /* Selected tags. Also the active-filter summary, so they use the canonical
7473 7487 selected tint rather than a new colour. */
7474 7488 .tag-chips { list-style: none; display: flex; flex-wrap: wrap; gap: var(--gap-bound); margin-bottom: var(--gap-section); }
7475 - .tag-chip { display: flex; align-items: center; gap: var(--gap-bound); padding: var(--gap-bound) var(--gap-peer); font-size: var(--text-fine); background: color-mix(in oklch, var(--action) 8%, transparent); border: 1px solid var(--focus-ring); }
7489 + .tag-chip { display: flex; align-items: center; gap: var(--gap-bound); padding: var(--gap-bound) var(--gap-peer); font-size: var(--text-fine); background: var(--highlight-faint); border: 1px solid var(--focus-ring); }
7476 7490 .tag-chip-label { overflow-wrap: anywhere; }
7477 7491 .tag-chip-remove { text-decoration: none; color: var(--content-secondary); font-size: var(--text-body); line-height: 1; padding: 0 var(--gap-bound); }
7478 7492 .tag-chip-remove:hover { color: var(--content); }
@@ -7573,7 +7587,7 @@
7573 7587 .issue-tab:hover { opacity: 1; }
7574 7588 .issue-tab.is-selected {
7575 7589 opacity: 1;
7576 - background: color-mix(in oklch, var(--action) 8%, transparent);
7590 + background: var(--highlight-faint);
7577 7591 border-color: var(--focus-ring);
7578 7592 }
7579 7593 .page-info { opacity: 0.6; }
@@ -7680,13 +7694,22 @@
7680 7694 .card--selectable input:checked + .card--selectable-inner,
7681 7695 .card--selectable.is-selected .card--selectable-inner {
7682 7696 border-color: var(--action);
7683 - background: color-mix(in oklch, var(--action) 8%, transparent);
7697 + background: var(--highlight-faint);
7684 7698 }
7685 7699
7686 7700 .card--selectable:hover .card--selectable-inner {
7687 7701 background: var(--surface-sunken);
7688 7702 }
7689 7703
7704 + /* Focus. The card is a <label> wrapping a visually hidden radio, so the focus
7705 + lands on the input and the ring has to be drawn on the inner surface. The
7706 + <a> form of the card takes focus itself. */
7707 + .card--selectable:focus-visible,
7708 + .card--selectable input:focus-visible + .card--selectable-inner {
7709 + outline: 2px solid var(--focus-ring);
7710 + outline-offset: 2px;
7711 + }
7712 +
7690 7713 .card--selectable-label {
7691 7714 font-weight: bold;
7692 7715 font-size: var(--text-note);
@@ -8336,6 +8359,7 @@
8336 8359
8337 8360 /* Sortable table column header */
8338 8361 .sortable-th { cursor: pointer; }
8362 + .sortable-th:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
8339 8363 .sort-arrow { opacity: 0.4; }
8340 8364 .sort-arrow--active { opacity: 1; }
8341 8365
@@ -24,9 +24,9 @@
24 24 <h1 class="page-title">{{ item.title }}</h1>
25 25 <div class="item-meta">
26 26 {% if item.is_public && item.publish_at.is_none() %}
27 - <span class="badge active">Active</span>
27 + <span class="badge badge--live">Active</span>
28 28 {% else if !item.is_public && item.publish_at.is_some() %}
29 - <span class="badge pending">Scheduled</span>
29 + <span class="badge badge--pending">Scheduled</span>
30 30 {% else %}
31 31 <span class="badge inactive">Draft</span>
32 32 {% endif %}
@@ -28,7 +28,7 @@
28 28 <td class="text-sm nowrap">{{ user.created_at }}</td>
29 29 <td>
30 30 {% if user.is_suspended %}
31 - <span class="badge suspended">Suspended</span>
31 + <span class="badge badge--failed">Suspended</span>
32 32 {% if user.has_pending_appeal %}
33 33 <span class="text-xs dimmed">(appeal pending)</span>
34 34 {% endif %}
@@ -52,7 +52,7 @@
52 52 </td>
53 53 <td class="nowrap">
54 54 {% if user.custom_pages_locked %}
55 - <span class="badge suspended">Locked</span>
55 + <span class="badge badge--failed">Locked</span>
56 56 <button class="btn-secondary small"
57 57 hx-post="/api/admin/users/{{ user.id }}/unlock-pages"
58 58 hx-target="#users-table"
@@ -28,7 +28,7 @@
28 28 {% if key.is_revoked %}
29 29 <span class="badge badge-error">Revoked</span>
30 30 {% else %}
31 - <span class="badge badge-success">Active</span>
31 + <span class="badge badge--live">Active</span>
32 32 {% endif %}
33 33 </td>
34 34 <td>{{ key.created_at }}</td>
@@ -18,7 +18,7 @@
18 18 <tbody>
19 19 {% for version in versions %}
20 20 <tr>
21 - <td><span class="badge{% if version.is_current %} current{% endif %}">v{{ version.number }}</span></td>
21 + <td><span class="badge{% if version.is_current %} badge--live{% endif %}">v{{ version.number }}</span></td>
22 22 <td class="item-version-upload-cell-sm">{% if let Some(label) = version.label %}{{ label }}{% endif %}</td>
23 23 <td class="item-version-upload-cell-sm">{% if version.has_file %}{% match version.file_name %}{% when Some with (name) %}{{ name }}{% when None %}1 file{% endmatch %}{% else %}<span class="item-version-upload-no-file">No file</span>{% endif %}</td>
24 24 <td>{{ version.size }}</td>
@@ -2,7 +2,7 @@
2 2
3 3 {% if enabled %}
4 4 <div class="totp-status-row">
5 - <span class="badge active">Enabled</span>
5 + <span class="badge badge--live">Enabled</span>
6 6 </div>
7 7
8 8 <div class="totp-section">
@@ -74,9 +74,29 @@
74 74 });
75 75 }
76 76
77 + /** Elements the browser already activates from the keyboard. */
78 + const NATIVELY_ACTIVATED = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
79 +
80 + /** Enter / Space on a `data-action` element the browser does not activate on
81 + * its own. The charter requires a focus ring on custom interactive containers
82 + * such as the sort headers, and a ring on something that cannot be operated
83 + * from the keyboard is decoration. Opt in with `tabindex="0"`. */
84 + function listenKeyActivate(): void {
85 + document.addEventListener('keydown', (evt) => {
86 + const key = (evt as KeyboardEvent).key;
87 + if (key !== 'Enter' && key !== ' ') return;
88 + const el = (evt.target as Element | null)?.closest('[data-action]');
89 + if (!el || NATIVELY_ACTIVATED.has(el.tagName) || !el.hasAttribute('tabindex')) return;
90 + evt.preventDefault();
91 + const verb = el.getAttribute('data-action');
92 + if (verb) run(el, verb, evt);
93 + });
94 + }
95 +
77 96 /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */
78 97 export function initActionDispatcher(): void {
79 98 listen('click', 'data-action', false);
99 + listenKeyActivate();
80 100 listen('change', 'data-change', false);
81 101 listen('input', 'data-input', false);
82 102 listen('submit', 'data-submit', true);
@@ -62,12 +62,12 @@
62 62 <tr>
63 63 <th class="col-3"><input type="checkbox" id="select-all" data-change="toggleSelectAllFromEl"></th>
64 64 <th class="col-5">#</th>
65 - <th class="col-32 sortable-th" data-action="sortContent" data-arg="2" data-arg2="text" title="Sort by title">Item <span class="sort-arrow" data-col="2"></span></th>
66 - <th class="col-12 sortable-th" data-action="sortContent" data-arg="3" data-arg2="text" title="Sort by type">Type <span class="sort-arrow" data-col="3"></span></th>
67 - <th class="col-10 sortable-th" data-action="sortContent" data-arg="4" data-arg2="money" title="Sort by price">Price <span class="sort-arrow" data-col="4"></span></th>
68 - <th class="col-10 sortable-th" data-action="sortContent" data-arg="5" data-arg2="num" title="Sort by sales">Sales <span class="sort-arrow" data-col="5"></span></th>
69 - <th class="col-10 sortable-th" data-action="sortContent" data-arg="6" data-arg2="money" title="Sort by revenue">Revenue <span class="sort-arrow" data-col="6"></span></th>
70 - <th class="col-8 sortable-th" data-action="sortContent" data-arg="7" data-arg2="text" title="Sort by status">Status <span class="sort-arrow" data-col="7"></span></th>
65 + <th class="col-32 sortable-th" tabindex="0" data-action="sortContent" data-arg="2" data-arg2="text" title="Sort by title">Item <span class="sort-arrow" data-col="2"></span></th>
66 + <th class="col-12 sortable-th" tabindex="0" data-action="sortContent" data-arg="3" data-arg2="text" title="Sort by type">Type <span class="sort-arrow" data-col="3"></span></th>
67 + <th class="col-10 sortable-th" tabindex="0" data-action="sortContent" data-arg="4" data-arg2="money" title="Sort by price">Price <span class="sort-arrow" data-col="4"></span></th>
68 + <th class="col-10 sortable-th" tabindex="0" data-action="sortContent" data-arg="5" data-arg2="num" title="Sort by sales">Sales <span class="sort-arrow" data-col="5"></span></th>
69 + <th class="col-10 sortable-th" tabindex="0" data-action="sortContent" data-arg="6" data-arg2="money" title="Sort by revenue">Revenue <span class="sort-arrow" data-col="6"></span></th>
70 + <th class="col-8 sortable-th" tabindex="0" data-action="sortContent" data-arg="7" data-arg2="text" title="Sort by status">Status <span class="sort-arrow" data-col="7"></span></th>
71 71 <th class="col-10">Actions</th>
72 72 </tr>
73 73 </thead>
@@ -76,9 +76,9 @@
76 76 <td class="proj-members-split-cell">{{ member.split_percent }}%</td>
77 77 <td>
78 78 {% if member.stripe_connected %}
79 - <span class="badge active">Connected</span>
79 + <span class="badge badge--live">Connected</span>
80 80 {% else %}
81 - <span class="badge pending">Not connected</span>
81 + <span class="badge badge--pending">Not connected</span>
82 82 {% endif %}
83 83 </td>
84 84 <td class="text-xs">{{ member.added_at }}</td>