Skip to main content

max / makenotwork

Describe the shared Export CSV and blog Delete acts, and write the ruling down 27d5e5b8, ruled (a) by Max: a described fragment inside an Askama page is legitimate at any granularity the vocabulary already names, primitive included, and its glue module is a sibling of widgets/ rather than a member of it. Written into src/quasi/widgets/mod.rs, which is what the next session reads, with the visual cost stated so it is not rediscovered. Then the two things the ruling buys, both of which are shared controls that converted at their own granularity instead of waiting for four screens. Export CSV, Shape 6 step 1 (17050ff5). Five buttons in four templates, each carrying data-action="exportCsvButton" plus the URL and the filename as positional arguments. Now one Action::post(route).saving(name).awaiting(), called from all five and from the described buyer-contacts screen, which had the only other copy. window.exportCsvButton is deleted and the frontend-globals seal goes 114 to 113 -- the wrapper that survived every earlier batch because four unconverted screens reference-counted it, gone without any of them. Blog delete, the first of Shape 8's fifty (b279b9eb). The same button is written twice, in project_content.html and project_blog.html, over the same route with the same prompt. Described once. The hand-written-confirm seal goes 50 to 48. It also gets Tone::Danger, which the templates were saying as class="danger-text" to the stylesheet alone, and which only started working on 2026-08-21 when 35756077 handed the layer back. Two candidates in the same pass were left hand-written rather than approximated: the Remove buttons in partials/link_row.html and partials/tag.html both target hx-target="closest .<class>", and a relative selector is not something Action::replaces can say. Filed as quasicoherent 5aa43815; the tag one additionally reads as an aria-label, which is aad33ecc. The five exports move from .btn-secondary to the design system's .button -- raised rather than sunken, and the app's own padding and text-sm go. That is the ruling's accepted cost, it is live on pages no flag gates, and it wants an eyeball.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 14:33 UTC
Signed with PGP, not checked
Commit: 5e3be3607383de3495d909304cffd44ebda4e52e
Parent: 1ea614c
14 files changed, +274 insertions, -37 deletions
@@ -45,11 +45,6 @@
45 45 document.getElementById('license-keys-section').classList.toggle('hidden', !this.checked);
46 46 };
47 47
48 - // --- CSV export buttons (project_subscriptions, buyer_contacts, user_payments, user_creator) ---
49 - window.exportCsvButton = function (url, filename) {
50 - this.textContent = 'Exporting...'; this.disabled = true; var btn = this; fetch(url, { method: 'POST', headers: csrfHeaders() }).then(function (r) { return r.blob(); }).then(function (b) { var a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = filename; a.click(); btn.textContent = 'Export CSV'; btn.disabled = false; }).catch(function () { btn.textContent = 'Export CSV'; btn.disabled = false; showToast('Export failed'); });
51 - };
52 -
53 48 // --- project_synckit.html / user_synckit.html ---
54 49 window.syncKitShowSlugFromBtn = function (appId) {
55 50 syncKitShowSlugForm(appId, this.dataset.slug);
@@ -55,7 +55,14 @@
55 55 /// `static/partial-item-text-editor.js` and left the seal at 115, so the
56 56 /// warning three paragraphs up describes this entry too. Nothing was removed
57 57 /// today; the number was walked down to what the tree already counted.
58 - const HIGH_WATER: usize = 114;
58 + ///
59 + /// 113 on 2026-08-22, and this one is progress rather than slack: Shape 6 step
60 + /// 1 (`17050ff5`) described the five Export CSV buttons through
61 + /// `crate::quasi::export_act`, so `window.exportCsvButton` has no call site
62 + /// left and is deleted. The wrapper that survived every earlier batch because
63 + /// it was reference-counted by four unconverted screens went without waiting
64 + /// for any of them, which is what the glue-module ruling (`27d5e5b8`) is for.
65 + const HIGH_WATER: usize = 113;
59 66
60 67 /// Every file whose global assignments count: the legacy scripts and the typed
61 68 /// modules that replaced them.
@@ -43,7 +43,20 @@
43 43 /// described, and are still live Askama structs because the conversions are
44 44 /// dual-serve while `QUASI_SCREENS` serves nobody. They leave with the templates
45 45 /// when the switch ships, which is task `64b33b26`.
46 - const HIGH_WATER: usize = 50;
46 + ///
47 + /// 48 on 2026-08-22, and the first two that left without their screens. The
48 + /// identical "Delete this blog post?" button in `tabs/project_content.html` and
49 + /// `tabs/project_blog.html` is described once in
50 + /// `crate::quasi::blog_delete_act` and called from both, which is what the
51 + /// glue-module ruling (`27d5e5b8`) bought: a shared act converts at its own
52 + /// granularity rather than once per screen. Both take `Tone::Danger`, which the
53 + /// templates were saying as `class="danger-text"` to the stylesheet alone.
54 + ///
55 + /// The two Remove buttons in `partials/link_row.html` and `partials/tag.html`
56 + /// were candidates in the same pass and are deliberately not converted: both
57 + /// target `hx-target="closest .<class>"`, and a relative selector is not
58 + /// something `Action::replaces` can say. Filed rather than approximated.
59 + const HIGH_WATER: usize = 48;
47 60
48 61 /// Every template, recursively. Walked rather than listed for the reason the
49 62 /// frontend-globals seal walks `frontend/src`: a fence that has to be told about
@@ -124,12 +124,12 @@
124 124 /// not, and `layout::Awaiting` takes a measurement rather than a stand-in for
125 125 /// one.
126 126 fn export() -> Node {
127 - Node::act(
128 - "Export CSV",
129 - Action::post("/api/export/contacts")
130 - .saving("contacts.csv")
131 - .awaiting(),
132 - )
127 + // Through `export_act` rather than spelled again here. This screen had the
128 + // only described copy when it was written; there are five call sites now
129 + // (`27d5e5b8`, the glue-module ruling), and one of them is the Askama
130 + // fallback for this very tab. Two spellings of one control is what the
131 + // conversion is for removing.
132 + super::export_act::act("/api/export/contacts", "contacts.csv")
133 133 }
134 134
135 135 /// The buyers who shared an email.
@@ -31,9 +31,11 @@
31 31 use crate::AppState;
32 32 use crate::auth::SessionUser;
33 33
34 + pub mod blog_delete_act;
34 35 pub mod buyer_contacts;
35 36 pub mod discover_typeahead;
36 37 pub mod embeds;
38 + pub mod export_act;
37 39 pub mod forum_memberships;
38 40 pub mod item_tabs;
39 41 pub mod library_contacts;
@@ -21,5 +21,44 @@
21 21 //! found a missing primitive on its way through
22 22 //! (`makeover_layout::Image`, 0.21.0), which is the argument for proving one in
23 23 //! an app before it becomes shared.
24 + //!
25 + //! # A primitive's glue is a sibling of this directory, never a member
26 + //!
27 + //! Ruled by Max 2026-08-21 (`27d5e5b8`), and written here because this doc is
28 + //! what gets read when someone asks where a described fragment goes.
29 + //!
30 + //! **A described fragment inside an Askama page is legitimate at any
31 + //! granularity the vocabulary already names, primitive included.** One `Field`
32 + //! qualifies; so does one `Act`. The question was whether that made a single
33 + //! primitive a widget, and the answer is that it does not — it makes it a
34 + //! primitive with an entry point. So the glue module sits at `src/quasi/`,
35 + //! beside `rich_field.rs`, `upload_field.rs` and `export_act.rs`, and this
36 + //! directory keeps meaning what the paragraphs above say it means: a name over
37 + //! an assembly. The "misuses the word widget" objection is answered by
38 + //! placement rather than by declining the pattern.
39 + //!
40 + //! What it buys is that a shared control converts at its own granularity
41 + //! instead of once per parent. `export_act.rs` is the first one taken: five
42 + //! Export CSV buttons in four templates, described once. The same applies to
43 + //! the shared act rows — `partials/link_row.html`, `partials/tag.html`, and the
44 + //! two identical delete buttons in `tabs/project_content.html` and
45 + //! `tabs/project_blog.html`.
46 + //!
47 + //! ## What it costs, stated so it is not rediscovered
48 + //!
49 + //! A live visual change on a page no flag gates. The templates write
50 + //! `class="btn-secondary text-sm"` and a described act emits `class="button"`,
51 + //! so the control lands on the design system's button rather than the app's:
52 + //! `--surface-raised` and `--bevel-raised` where `.btn-secondary` sets
53 + //! `--surface-sunken`, `--shadow-raised` and its own padding and font size.
54 + //! That is the swap, it is deliberate, and it wants an eyeball rather than a
55 + //! test.
56 + //!
57 + //! It is not, any more, a loss of the design system's button altogether.
58 + //! `35756077` measured that `style.css` is unlayered and outranks
59 + //! `@layer makeover` at any specificity, so a bare `button { }` rule was taking
60 + //! the surface, the bevel **and the tone** off every described act. Fixed by
61 + //! the `revert-layer` handoff at `style.css:473`. A described act that reaches
62 + //! for `Tone::Danger` gets it now, which it did not before 2026-08-21.
24 63
25 64 pub mod carousel;
@@ -8,10 +8,7 @@
8 8 </p>
9 9 {% if !contacts.is_empty() %}
10 10 <div class="mb-section">
11 - <button class="btn-secondary text-sm"
12 - hx-post="/api/export/contacts"
13 - hx-swap="none"
14 - data-action="exportCsvButton" data-arg="/api/export/contacts" data-arg2="contacts.csv">Export CSV</button>
11 + {{ crate::quasi::export_act::html("/api/export/contacts", "contacts.csv")|safe }}
15 12 </div>
16 13 <div class="scroll-x">
17 14 <table class="data-table contacts-table well">
@@ -34,11 +34,7 @@
34 34 <div class="blog-post-actions">
35 35 <a href="/p/{{ project_slug }}/blog/{{ post.slug }}" class="btn-secondary small">View</a>
36 36 <a href="/dashboard/project/{{ project_slug }}/blog/new?post={{ post.id }}" class="btn-secondary small">Edit</a>
37 - <button class="btn-secondary small danger-text"
38 - hx-delete="/api/blog/{{ post.id }}"
39 - hx-target="#post-row-{{ post.id }}"
40 - hx-swap="outerHTML"
41 - hx-confirm="Delete this blog post?">Delete</button>
37 + {{ crate::quasi::blog_delete_act::html(post.id.to_string().as_str())|safe }}
42 38 </div>
43 39 </td>
44 40 </tr>
@@ -212,11 +212,7 @@
212 212 <div class="item-actions">
213 213 <a href="/p/{{ project_slug }}/blog/{{ post.slug }}" class="btn-secondary small">View</a>
214 214 <a href="/dashboard/project/{{ project_slug }}/blog/new?post={{ post.id }}" class="btn-secondary small">Edit</a>
215 - <button class="btn-secondary small danger-text"
216 - hx-delete="/api/blog/{{ post.id }}"
217 - hx-target="#post-row-{{ post.id }}"
218 - hx-swap="outerHTML"
219 - hx-confirm="Delete this blog post?">Delete</button>
215 + {{ crate::quasi::blog_delete_act::html(post.id.to_string().as_str())|safe }}
220 216 </div>
221 217 </td>
222 218 </tr>
@@ -90,8 +90,7 @@
90 90 <p class="muted m-0">{{ subscriber_count }} active member{% if subscriber_count != 1 %}s{% endif %}</p>
91 91 </div>
92 92 {% if subscriber_count > 0 %}
93 - <button class="btn-secondary text-sm"
94 - data-action="exportCsvButton" data-arg="/api/export/subscriptions" data-arg2="members.csv">Export CSV</button>
93 + {{ crate::quasi::export_act::html("/api/export/subscriptions", "members.csv")|safe }}
95 94 {% endif %}
96 95 </div>
97 96
@@ -124,8 +124,9 @@
124 124 {% endif %}
125 125 </p>
126 126 {% if follower_count > 0 %}
127 - <button class="btn-secondary text-sm broadcast-export nowrap"
128 - data-action="exportCsvButton" data-arg="/api/export/followers" data-arg2="followers.csv">Export CSV</button>
127 + {# broadcast-export and nowrap are this page's layout, not the control's,
128 + so they stay on a wrapper the description does not own. #}
129 + <span class="broadcast-export nowrap">{{ crate::quasi::export_act::html("/api/export/followers", "followers.csv")|safe }}</span>
129 130 {% endif %}
130 131 </div>
131 132 <form hx-post="/api/broadcast" hx-target="#broadcast-result" hx-swap="innerHTML"