Skip to main content

max / makenotwork

Describe the project page, and the last hand-written feed link is gone `/p/{slug}` is a described document. `templates/pages/project.html` and `ProjectTemplate` are gone, the route stays the axum handler it was, and with it the third and last `<link rel="alternate">` this server wrote by hand: every feed the site publishes is now said by a screen and spelled by `FeedKind`. Four behaviours the vocabulary took over, and two scripts deleted with them: - The section tabs are a `TabGroup` whose children carry a label, so the strip and the switching are the renderer's. `page-project.js` is gone. - The gallery goes through `quasi::widgets::carousel::region`, the described half of the widget the Askama macro already called, so the storefront and the three templates still calling that macro cannot be two carousels. - The tip offer's disclosure is `Slot::showing_at_most_one`. - The share link is `Act::copies`. Three things did not survive. The grid/list toggle: two view copies of one set with the choice in localStorage, where the list copy carried strictly less than the grid copy, so what it offered was one full view and one lossy one. There is no member for how densely a list is drawn and there should not be. `.follow-btn.is-selected`, for the reason the profile lost it. And the promo code's `<details>`, because a `Reveal` names its control by name and every tier card would have named the same one. Two places stay markup and the module header says why. The section bodies have been through `render_creator_markdown` -- media paths rewritten against the CDN under the creator's id, off-platform media stripped, video files turned into `<video>` -- none of which is derivable from the source, so handing quasi the source would drop three transforms silently. The report dialogue is a control opening a dialogue, which `RegionKind::Modal` is not; it is now `ReportModalTemplate` over the file `pages/item.html` still includes, so there is one copy of the markup and two callers. `helpers::redirect_to` is new. A described form is an htmx post, and both Stripe checkout handlers end at a 303 the browser follows inside the XHR, leaving htmx to swap a cross-origin fetch into the control that sent it. That is the failure `c7b0d3c1` hit on the header's Log Out, and this is `logout_handler`'s pair spelled once for the three sites the conversion made. Claiming a free item says the surface is stale rather than answering into a region: the row it was pressed in is not a region, and the item is owned now, so a reload leaves a card offering the library instead of a status line stuck in a card that still says Add.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-02 14:34 UTC
Signed with PGP, not checked
Commit: 7b5925324ad4263cfc2554e67df5d9d147c6e723
Parent: dbe9968
16 files changed, +1023 insertions, -688 deletions
@@ -731,21 +731,18 @@
731 731 Add new opener selectors here as they appear. */
732 732 .context-menu-btn,
733 733 .discover-filter-toggle,
734 - .tip-toggle,
735 734 .bundle-toggle {
736 735 box-shadow: none;
737 736 }
738 737
739 738 .context-menu-btn:hover,
740 739 .discover-filter-toggle:hover,
741 - .tip-toggle:hover,
742 740 .bundle-toggle:hover {
743 741 box-shadow: none;
744 742 }
745 743
746 744 .context-menu-btn:active,
747 745 .discover-filter-toggle:active,
748 - .tip-toggle:active,
749 746 .bundle-toggle:active {
750 747 box-shadow: var(--shadow-inset);
751 748 }
@@ -1912,187 +1909,14 @@
1912 1909
1913 1910 /* ===========================================
1914 1911 PROJECT PAGE (store front)
1915 - =========================================== */
1912 + ===========================================
1916 1913
1917 - /* Project store-front page (templates/pages/project.html). Scoped under
1918 - .project-page body class, `.item-card`, `.item-title`, `.item-meta`,
1919 - `.item-price`, etc. here mean store-front item cards, distinct from
1920 - the item-detail-page versions scoped under .item-page above. */
1921 - .project-page .store-header { margin-bottom: var(--gap-page); }
1922 - .project-page .store-title { font-size: var(--text-hero); margin-bottom: var(--gap-peer); }
1923 - .project-page .store-meta {
1924 - font-size: var(--text-note);
1925 - opacity: 0.7;
1926 - margin-bottom: var(--gap-pane);
1927 - }
1928 - .project-page .store-meta a { color: var(--content); }
1929 - .project-page .store-description {
1930 - font-size: var(--text-lead);
1931 - max-width: 800px;
1932 - margin-bottom: var(--gap-page);
1933 - text-align: left;
1934 - }
1935 - .project-page .store-actions {
1936 - display: flex;
1937 - gap: var(--gap-section);
1938 - flex-wrap: wrap;
1939 - }
1940 -
1941 - .project-page .items-section { margin-bottom: var(--gap-page); }
1942 -
1943 - .project-page .items-grid {
1944 - display: grid;
1945 - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
1946 - gap: var(--gap-page);
1947 - }
1948 -
1949 - .project-page .item-card {
1950 - background: var(--surface-overlay);
1951 - transition: background 0.2s ease;
1952 - cursor: pointer;
1953 - display: flex;
1954 - flex-direction: column;
1955 - height: 100%;
1956 - }
1957 -
1958 - .project-page .item-card:hover { background: var(--surface-sunken); }
1959 -
1960 - .project-page a.item-thumbnail {
1961 - width: 100%;
1962 - height: 300px;
1963 - background: var(--border);
1964 - display: flex;
1965 - align-items: center;
1966 - justify-content: center;
1967 - font-size: var(--text-hero);
1968 - text-decoration: none;
1969 - overflow: hidden;
1970 - }
1971 -
1972 - .project-page a.item-thumbnail img {
1973 - width: 100%;
1974 - height: 100%;
1975 - object-fit: cover;
1976 - display: block;
1977 - }
1978 -
1979 - /* The dimming belongs to the placeholder, not to the slot. It was on the slot,
1980 - so the day covers started rendering here they would have rendered at 30%. */
1981 - .project-page a.item-thumbnail-empty { opacity: 0.3; }
1982 - .project-page a.item-thumbnail-empty:hover { opacity: 0.5; }
1983 -
1984 - .project-page .item-content {
1985 - padding: var(--gap-pane);
1986 - display: flex;
1987 - flex-direction: column;
1988 - flex-grow: 1;
1989 - }
1990 -
1991 - .project-page .item-title {
1992 - font-size: var(--text-subhead);
1993 - margin-bottom: var(--gap-peer);
1994 - font-family: var(--font-display);
1995 - font-weight: bold;
1996 - }
1997 -
1998 - .project-page .item-title a:hover { text-decoration: underline; }
1999 -
2000 - .project-page .item-meta {
2001 - font-size: var(--text-note);
2002 - opacity: 0.7;
2003 - margin-bottom: var(--gap-section);
2004 - }
2005 -
2006 - .project-page .item-tags { margin-bottom: var(--gap-section); }
2007 -
2008 - .project-page .item-description {
2009 - font-size: var(--text-note);
2010 - margin-bottom: var(--gap-section);
2011 - opacity: 0.8;
2012 - text-align: left;
2013 - flex-grow: 1;
2014 - }
2015 -
2016 - .project-page .item-footer {
2017 - display: flex;
2018 - justify-content: space-between;
2019 - align-items: center;
2020 - margin-top: auto;
2021 - }
2022 -
2023 - .project-page .item-price { font-size: var(--text-subhead); }
2024 - .project-page .item-stats { font-size: var(--text-note); opacity: 0.6; }
2025 -
2026 - .project-page .store-footer {
2027 - margin-top: var(--gap-page);
2028 - padding-top: var(--gap-page);
2029 - border-top: 1px solid var(--border);
2030 - opacity: 0.6;
2031 - }
2032 -
2033 - .project-page .store-footer a { color: var(--content); }
2034 -
2035 - .project-page .item-content .btn-primary,
2036 - .project-page .item-content .btn-secondary {
2037 - width: 100%;
2038 - margin-top: var(--gap-section);
2039 - }
2040 -
2041 - /* project.html allows .section-tabs to wrap (other pages don't). */
2042 - .project-page .section-tabs { flex-wrap: wrap; }
2043 -
2044 - .project-page .tiers-section { margin-bottom: var(--gap-page); }
2045 -
2046 - .project-page .tiers-grid {
2047 - display: grid;
2048 - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2049 - gap: var(--gap-pane);
2050 - }
2051 -
2052 - .project-page .tier-card {
2053 - background: var(--surface-overlay);
2054 - padding: var(--gap-page);
2055 - display: flex;
2056 - flex-direction: column;
2057 - }
2058 -
2059 - .project-page .tier-name {
2060 - font-family: var(--font-display);
2061 - font-weight: bold;
2062 - font-size: var(--text-subhead);
2063 - margin-bottom: var(--gap-peer);
2064 - }
2065 -
2066 - .project-page .tier-price {
2067 - font-size: var(--text-head);
2068 - margin-bottom: var(--gap-section);
2069 - }
2070 -
2071 - .project-page .tier-description {
2072 - font-size: var(--text-note);
2073 - opacity: 0.8;
2074 - margin-bottom: var(--gap-pane);
2075 - flex-grow: 1;
2076 - text-align: left;
2077 - }
2078 -
2079 - .project-page .tier-card form { margin-top: auto; }
2080 - .project-page .tier-card button,
2081 - .project-page .tier-card .btn-primary { width: 100%; }
2082 -
2083 - .project-page .tier-active-badge {
2084 - font-size: var(--text-note);
2085 - padding: var(--gap-peer) var(--gap-section);
2086 - background: var(--surface-sunken);
2087 - text-align: center;
2088 - opacity: 0.7;
2089 - }
2090 -
2091 - @media (max-width: 599px) {
2092 - .project-page .store-title { font-size: var(--text-title); }
2093 - .project-page a.item-thumbnail { height: 200px; }
2094 - .project-page .store-description { font-size: var(--text-body); }
2095 - }
1914 + The store front is described (`src/quasi/project.rs`), so the header, the
1915 + item cards, the two view copies, the tab strip and the tier cards are all
1916 + the design system's markup now: a rule restating one of them fails the
1917 + drift check in build.rs. What is this page's rather than the system's is
1918 + how wide it runs, which is the measure on the body, and nothing else was
1919 + left. */
2096 1920
2097 1921 /* ===========================================
2098 1922 LIBRARY DOWNLOADS PAGE
@@ -4363,8 +4187,7 @@
4363 4187 .library-page .library-downloads,
4364 4188 .library-page .downloads-hero,
4365 4189 .receipt-page .receipt-box,
4366 - .stripe-disclaimer-page .disclaimer-box,
4367 - .project-page .project-sections {
4190 + .stripe-disclaimer-page .disclaimer-box {
4368 4191 padding: var(--gap-page);
4369 4192 margin-bottom: var(--gap-page);
4370 4193 }
@@ -7691,88 +7514,9 @@
7691 7514 border-top: 1px solid var(--border);
7692 7515 }
7693 7516
7694 - /* ── Tips ── */
7695 -
7696 - .tip-section {
7697 - margin-top: var(--gap-section);
7698 - text-align: center;
7699 - }
7700 -
7701 - .tip-toggle {
7702 - font-family: var(--font-mono);
7703 - font-size: var(--text-note);
7704 - padding: var(--gap-peer) var(--gap-pane);
7705 - background: none;
7706 - border: 1px solid var(--content);
7707 - color: var(--content);
7708 - cursor: pointer;
7709 - text-decoration: none;
7710 - display: inline-block;
7711 - }
7712 -
7713 - .tip-toggle:hover {
7714 - background: var(--content);
7715 - color: var(--surface-page);
7716 - }
7717 -
7718 - .tip-form {
7719 - margin-top: var(--gap-section);
7720 - display: flex;
7721 - flex-direction: column;
7722 - gap: var(--gap-section);
7723 - max-width: 300px;
7724 - margin-left: auto;
7725 - margin-right: auto;
7726 - text-align: left;
7727 - }
7728 -
7729 - .tip-form.hidden {
7730 - display: none;
7731 - }
7732 -
7733 - .tip-amount-row {
7734 - display: flex;
7735 - align-items: center;
7736 - gap: var(--gap-bound);
7737 - }
7738 -
7739 - .tip-amount-row .pricing-currency {
7740 - font-size: var(--text-subhead);
7741 - opacity: 0.7;
7742 - }
7743 -
7744 - .tip-amount-input {
7745 - width: 80px;
7746 - font-size: var(--text-subhead);
7747 - padding: var(--gap-peer);
7748 - border: 1px solid var(--border);
7749 - background: var(--surface-overlay);
7750 - font-family: var(--font-mono);
7751 - }
7752 -
7753 - .tip-message-input {
7754 - width: 100%;
7755 - font-size: var(--text-note);
7756 - padding: var(--gap-peer);
7757 - border: 1px solid var(--border);
7758 - background: var(--surface-overlay);
7759 - font-family: var(--font-sans);
7760 - resize: vertical;
7761 - }
7762 -
7763 - .tip-submit {
7764 - font-family: var(--font-mono);
7765 - font-size: var(--text-note);
7766 - padding: var(--gap-peer) var(--gap-section);
7767 - background: var(--content);
7768 - color: var(--surface-page);
7769 - border: none;
7770 - cursor: pointer;
7771 - }
7772 -
7773 - .tip-submit:hover {
7774 - opacity: 0.85;
7775 - }
7517 + /* The tip offer is described (`src/quasi/tip.rs`), so its button, its
7518 + disclosure, its two fields and its submit are the design system's. What was
7519 + here restated them, and a restated rule fails the drift check. */
7776 7520
7777 7521 /* ===========================================
7778 7522 COLLECTION PICKER (shared dropdown)
@@ -9221,44 +8965,6 @@
9221 8965 margin-top: var(--gap-bound);
9222 8966 }
9223 8967
9224 - /* ===========================================
9225 - PROJECT PAGE (replaces inline styles in pages/project.html)
9226 - =========================================== */
9227 -
9228 - .project-page .store-cover {
9229 - width: 120px;
9230 - height: 120px;
9231 - border-radius: var(--radius-panel);
9232 - object-fit: cover;
9233 - margin-bottom: var(--gap-pane);
9234 - }
9235 - .project-page .follow-btn.is-selected {
9236 - opacity: 0.7;
9237 - }
9238 - .project-page .follower-count-muted {
9239 - font-size: var(--text-note);
9240 - opacity: 0.7;
9241 - padding: var(--gap-peer) 0;
9242 - }
9243 - .project-page .promo-details {
9244 - font-size: var(--text-note);
9245 - margin-bottom: var(--gap-peer);
9246 - }
9247 - .project-page .promo-details summary {
9248 - cursor: pointer;
9249 - opacity: 0.7;
9250 - }
9251 - .project-page .promo-input {
9252 - width: 100%;
9253 - margin-top: var(--gap-peer);
9254 - padding: var(--gap-bound) var(--gap-peer);
9255 - font-size: var(--text-note);
9256 - text-transform: uppercase;
9257 - }
9258 - .project-page .store-footer-links {
9259 - margin-top: var(--gap-peer);
9260 - }
9261 -
9262 8968 /* ===========================================
9263 8969 INSERTION LIST (replaces inline styles in partials/insertion_list.html)
9264 8970 =========================================== */
@@ -86,6 +86,27 @@
86 86 headers.get("HX-Request").is_some()
87 87 }
88 88
89 + /// Send the caller somewhere else, whichever way they asked.
90 + ///
91 + /// A 303 is what a browser following a form submission wants and it is the
92 + /// wrong answer to an htmx request: the browser follows the redirect inside the
93 + /// XHR and htmx swaps whatever came back into the element that sent it, which
94 + /// for a hop to Stripe is a cross-origin fetch that lands nowhere. `HX-Redirect`
95 + /// navigates the document instead. `c7b0d3c1` hit this on the header's Log Out
96 + /// and `logout_handler` carries the same pair.
97 + ///
98 + /// Here rather than in each handler because the described forms made it three
99 + /// call sites at once: the tip form, the tier subscribe form, and the paths
100 + /// either one bails out of before reaching Stripe.
101 + pub fn redirect_to(headers: &HeaderMap, location: &str) -> Response {
102 + if is_htmx_request(headers)
103 + && let Ok(value) = HeaderValue::from_str(location)
104 + {
105 + return (StatusCode::OK, [("HX-Redirect", value)], "").into_response();
106 + }
107 + axum::response::Redirect::to(location).into_response()
108 + }
109 +
89 110 /// Check the client's `If-None-Match` header against a cache generation.
90 111 /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
91 112 pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
@@ -25,7 +25,7 @@
25 25 pub use db::{SLUG_SUFFIX_CAP, insert_with_unique_slug, is_unique_violation, map_unique_violation};
26 26 pub use http::{
27 27 HTTP_CLIENT, check_etag, extract_client_ip, htmx_toast_response, hx_toast,
28 - ip_advisory_lock_key, is_htmx_request, with_etag,
28 + ip_advisory_lock_key, is_htmx_request, redirect_to, with_etag,
29 29 };
30 30 pub use integration::{fetch_discussion_info, get_csrf_token};
31 31 pub use render::{escape_html, render_fragment};
@@ -68,6 +68,7 @@
68 68 pub mod payout_summary;
69 69 pub mod policy;
70 70 pub mod pricing;
71 + pub mod project;
71 72 pub mod project_analytics;
72 73 pub mod project_blog;
73 74 pub mod project_content;
@@ -145,7 +145,6 @@
145 145 LibraryTemplate,
146 146 CartTemplate,
147 147 OAuthAuthorizeTemplate,
148 - ProjectTemplate,
149 148 ProjectPaywallTemplate,
150 149 ItemTemplate,
151 150 LibraryAudioTemplate,
@@ -245,6 +244,8 @@
245 244 LibraryCollectionsTabTemplate,
246 245 // Follow button
247 246 TagFollowToggleTemplate,
247 + // Reporting
248 + ReportModalTemplate,
248 249 // Tag suggestions
249 250 TagSuggestionsTemplate,
250 251 // Item analytics
@@ -82,6 +82,29 @@
82 82 }
83 83 }
84 84
85 + /// The report dialogue, for a page that draws its own document.
86 + ///
87 + /// The same `partials/report_modal.html` `pages/item.html` includes, reachable
88 + /// as a string so `crate::quasi::project` can pay the handover its screen
89 + /// declares. One file, two callers: an Askama include and a fill.
90 + #[derive(Template)]
91 + #[template(path = "partials/report_modal.html")]
92 + pub struct ReportModalTemplate {
93 + /// What is being reported, as the copy and the hidden field spell it.
94 + pub report_target_type: &'static str,
95 + /// Which one.
96 + pub report_target_id: String,
97 + /// Whether "Mislabeled" is one of the reasons, which it is for anything
98 + /// carrying an AI tier.
99 + pub report_has_labels: bool,
100 + }
101 +
102 + impl ReportModalTemplate {
103 + pub fn render_string(&self) -> crate::error::Result<String> {
104 + crate::helpers::render_fragment(self)
105 + }
106 + }
107 +
85 108 /// HTMX partial: success/error status message after form submission.
86 109 #[derive(Template)]
87 110 #[template(path = "partials/form_status.html")]