Skip to main content

max / makenotwork

Describe the public profile, so a screen says its feed The second of the three pages that wrote `<link rel="alternate">` by hand. `/u/{username}` is a described document now: `templates/pages/user.html` and `UserTemplate` are gone, and the route stays the axum handler it was, for the reason `project_blog` states. Three things the page needed that the vocabulary already had: - `Document::styled` for the creator's Tier 0 theme. The template injected an unlayered `<style id="creator-theme">` so it would outrank every named layer, and that member writes the same sheet last in the head and outside the layer statement. Nothing keyed off the id. - `Act::copies` for the share link, which is what `data-copy-link` meant. The value is absolute now, since a relative path on a clipboard is not an address. - `Discovery` for the whole `{% block head %}`, minus the JSON-LD, which goes in through `Shell::with_head` rather than growing the vocabulary a member for one tag on three pages. The follow control was written three times, and is now written once. `src/quasi/follow.rs` holds it, both pages take it from there, and `routes/api/follows` answers a press with the same node, so the answer cannot disagree with the page that drew it. `follow_button.html` and `FollowButtonTemplate` go with it. What did not survive is `.follow-btn.is-selected`: the vocabulary has no latched control, and the label already says which state the button is in. `src/quasi/tip.rs` describes the tip offer both public pages carry. The disclosure is `Slot::showing_at_most_one` rather than `toggleTipForm`, and the hidden `_csrf` goes the way the reset token did, since `create_tip_checkout` already reads the token header-first. `_hyperscript.min.js` is vendored. `Shell::under` links it on every described document and this server never served it, so the file 404'd once per page and every disclosure and tab strip a screen could draw was inert.
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:14 UTC
Signed with PGP, not checked
Commit: dbe996845859c1f33ec11debd57f11783b6fb5db
Parent: 3bdde50
13 files changed, +970 insertions, -340 deletions
@@ -399,8 +399,12 @@
399 399 per width tier; new pages should add their body class here rather than
400 400 defining a new .foo-page .container rule. */
401 401 .delete-account-page .container,
402 - .receipt-page .container,
403 - .user-page .container { max-width: 600px; margin: 0 auto; }
402 + .receipt-page .container { max-width: 600px; margin: 0 auto; }
403 +
404 + /* The profile is described, so it has no container: the measure lands on the
405 + element the renderer draws the page into. The width is this page's own and
406 + the rest of the block was the design system's. */
407 + .user-page main { max-width: 600px; }
404 408
405 409 .import-page .container,
406 410 .export-page .container { max-width: 800px; margin: 0 auto; }
@@ -3601,63 +3605,6 @@
3601 3605 /* (Stripe Connect disclaimer page rules defined in the canonical
3602 3606 STRIPE DISCLAIMER PAGE section near end of file.) */
3603 3607
3604 - /* User profile page (templates/pages/user.html). */
3605 - .user-page .profile-header { text-align: center; margin-bottom: var(--gap-page); }
3606 -
3607 - .user-page .profile-avatar {
3608 - width: 120px;
3609 - height: 120px;
3610 - background: var(--surface-overlay);
3611 - margin: 0 auto var(--gap-section);
3612 - display: flex;
3613 - align-items: center;
3614 - justify-content: center;
3615 - font-size: var(--text-hero);
3616 - opacity: 0.5;
3617 - }
3618 -
3619 - .user-page .profile-name { font-size: var(--text-title); margin-bottom: var(--gap-peer); }
3620 -
3621 - .user-page .profile-username {
3622 - font-size: var(--text-note);
3623 - opacity: 0.7;
3624 - margin-bottom: var(--gap-section);
3625 - }
3626 -
3627 - .user-page .profile-bio {
3628 - font-size: var(--text-body);
3629 - max-width: 500px;
3630 - margin: 0 auto;
3631 - text-align: center;
3632 - }
3633 -
3634 - .user-page .links-section { margin-bottom: var(--gap-page); }
3635 - .user-page .link-item { text-align: center; }
3636 -
3637 - .user-page .link-title {
3638 - font-size: var(--text-lead);
3639 - margin-bottom: var(--gap-bound);
3640 - font-family: var(--font-display);
3641 - font-weight: bold;
3642 - }
3643 -
3644 - .user-page .link-description { font-size: var(--text-note); opacity: 0.7; }
3645 -
3646 - .user-page .project-title {
3647 - font-size: var(--text-lead);
3648 - margin-bottom: var(--gap-peer);
3649 - font-family: var(--font-display);
3650 - font-weight: bold;
3651 - }
3652 -
3653 - .user-page .project-meta {
3654 - font-size: var(--text-note);
3655 - opacity: 0.7;
3656 - margin-bottom: var(--gap-peer);
3657 - }
3658 -
3659 - .user-page .project-description { font-size: var(--text-note); opacity: 0.8; }
3660 -
3661 3608 /* The creators page moved to the description layer. Its headings, its ordered
3662 3609 list, its stat box, its four-column table and its call-to-action box are the
3663 3610 design system's now: the count is a `Figure`, the tiers are a `Node::Table`
@@ -4550,24 +4497,6 @@
4550 4497 color: var(--content-muted);
4551 4498 }
4552 4499
4553 - .profile-footer {
4554 - margin-top: var(--gap-page);
4555 - padding-top: var(--gap-pane);
4556 - border-top: 1px solid var(--border);
4557 - text-align: center;
4558 - opacity: 0.6;
4559 - font-size: var(--text-note);
4560 - }
4561 -
4562 - .profile-footer a {
4563 - color: var(--content);
4564 - text-decoration: none;
4565 - }
4566 -
4567 - .profile-footer a:hover {
4568 - text-decoration: underline;
4569 - }
4570 -
4571 4500 /* Site-wide header.
4572 4501 The class names are quasi-webview's, not this stylesheet's: the header is a
4573 4502 `quasi_router::Chrome::band` and `crate::shell::site_header` builds the
@@ -9642,56 +9571,6 @@
9642 9571 }
9643 9572
9644 9573
9645 - /* ===========================================
9646 - USER PROFILE PAGE (replaces inline styles in pages/user.html)
9647 - =========================================== */
9648 -
9649 - .user-page .creator-paused-notice {
9650 - margin-bottom: var(--gap-pane);
9651 - padding: var(--gap-section);
9652 - background: var(--surface-sunken);
9653 - border: 1px solid var(--border);
9654 - text-align: center;
9655 - font-size: var(--text-body);
9656 - }
9657 -
9658 - .user-page .profile-action-row {
9659 - margin-top: var(--gap-section);
9660 - }
9661 -
9662 - .user-page .profile-edit-btn {
9663 - display: inline-block;
9664 - padding: var(--gap-peer) var(--gap-section);
9665 - text-decoration: none;
9666 - font-size: var(--text-note);
9667 - }
9668 -
9669 - .user-page .follow-btn.is-selected {
9670 - opacity: 0.7;
9671 - }
9672 -
9673 - .user-page .follower-count {
9674 - margin-top: var(--gap-section);
9675 - font-size: var(--text-note);
9676 - opacity: 0.7;
9677 - }
9678 -
9679 - .user-page .profile-share-row {
9680 - margin-top: var(--gap-section);
9681 - display: flex;
9682 - gap: var(--gap-section);
9683 - justify-content: center;
9684 - font-size: var(--text-note);
9685 - }
9686 -
9687 - .user-page .profile-share-link {
9688 - opacity: 0.7;
9689 - }
9690 -
9691 - .user-page .collections-section {
9692 - margin-bottom: var(--gap-page);
9693 - }
9694 -
9695 9574 /* ===========================================
9696 9575 SUSPENSION BANNER (replaces inline styles in partials/suspension_banner.html)
9697 9576 =========================================== */
@@ -52,6 +52,7 @@
52 52 pub mod export_portal;
53 53 pub mod fan_plus;
54 54 pub mod feeds;
55 + pub mod follow;
55 56 pub mod forum_memberships;
56 57 pub mod git_explore;
57 58 pub mod git_repos;
@@ -82,8 +83,10 @@
82 83 pub mod shortcuts;
83 84 pub mod ssh_keys;
84 85 pub mod team;
86 + pub mod tip;
85 87 pub mod upload_field;
86 88 pub mod use_cases;
89 + pub mod user;
87 90 pub mod user_analytics;
88 91 pub mod user_projects;
89 92 pub mod user_support;
@@ -145,7 +145,6 @@
145 145 LibraryTemplate,
146 146 CartTemplate,
147 147 OAuthAuthorizeTemplate,
148 - UserTemplate,
149 148 ProjectTemplate,
150 149 ProjectPaywallTemplate,
151 150 ItemTemplate,
@@ -245,7 +244,6 @@
245 244 LibraryPurchasesTabTemplate,
246 245 LibraryCollectionsTabTemplate,
247 246 // Follow button
248 - FollowButtonTemplate,
249 247 TagFollowToggleTemplate,
250 248 // Tag suggestions
251 249 TagSuggestionsTemplate,
@@ -732,18 +732,6 @@
732 732 pub license_keys: Vec<crate::types::LicenseKeyRow>,
733 733 }
734 734
735 - // Follow Button Partial
736 -
737 - /// HTMX follow/unfollow toggle button.
738 - #[derive(Template)]
739 - #[template(path = "partials/follow_button.html")]
740 - pub struct FollowButtonTemplate {
741 - pub target_type: String,
742 - pub target_id: String,
743 - pub is_following: bool,
744 - pub follower_count: i64,
745 - }
746 -
747 735 // Tag Follow Toggle (compact, for discover sidebar)
748 736
749 737 /// Compact follow/unfollow toggle for tags in the discover sidebar.
@@ -2,7 +2,7 @@
2 2
3 3 use axum::{
4 4 extract::{Path, State},
5 - response::{IntoResponse, Response},
5 + response::{Html, IntoResponse, Response},
6 6 };
7 7 use uuid::Uuid;
8 8
@@ -15,7 +15,7 @@
15 15 auth::AuthUser,
16 16 db::{self, FollowTargetType, UserId},
17 17 error::{AppError, Result},
18 - templates::{FollowButtonTemplate, TagFollowToggleTemplate},
18 + templates::TagFollowToggleTemplate,
19 19 };
20 20
21 21 /// POST /api/follow/{target_type}/{target_id}: follow a user or project.
@@ -139,12 +139,12 @@
139 139
140 140 let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
141 141
142 - Ok(FollowButtonTemplate {
143 - target_type: target_type.to_string(),
144 - target_id: target_id.to_string(),
145 - is_following: true,
146 - follower_count: count,
147 - }
142 + Ok(Html(crate::quasi::follow::answered(
143 + &target_type.to_string(),
144 + &target_id.to_string(),
145 + true,
146 + count,
147 + ))
148 148 .into_response())
149 149 }
150 150
@@ -186,12 +186,12 @@
186 186
187 187 let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
188 188
189 - Ok(FollowButtonTemplate {
190 - target_type: target_type.to_string(),
191 - target_id: target_id.to_string(),
192 - is_following: false,
193 - follower_count: count,
194 - }
189 + Ok(Html(crate::quasi::follow::answered(
190 + &target_type.to_string(),
191 + &target_id.to_string(),
192 + false,
193 + count,
194 + ))
195 195 .into_response())
196 196 }
197 197
@@ -15,8 +15,8 @@
15 15
16 16 use crate::auth::SessionUser;
17 17 use crate::types::{
18 - Chapter, Collection, CustomLink, DiscoverItem, DiscoverProject, Item, ItemContent, ItemSection,
19 - Project, SidebarView, SubscriptionTier, TagBreadcrumb, TagTreeNode, User, Version,
18 + Chapter, DiscoverItem, DiscoverProject, Item, ItemContent, ItemSection, Project, SidebarView,
19 + SubscriptionTier, TagBreadcrumb, TagTreeNode, Version,
20 20 };
21 21
22 22 use super::CsrfTokenOption;
@@ -254,40 +254,6 @@
254 254 pub error_message: Option<String>,
255 255 }
256 256
257 - /// Public user profile page.
258 - #[derive(Template)]
259 - #[template(path = "pages/user.html")]
260 - #[allow(dead_code)] // Fields used by Askama template
261 - pub struct UserTemplate {
262 - pub csrf_token: CsrfTokenOption,
263 - pub session_user: Option<SessionUser>,
264 - pub user: User,
265 - pub custom_links: Vec<CustomLink>,
266 - pub projects: Vec<Project>,
267 - pub public_collections: Vec<Collection>,
268 - /// User ID for the follow button target.
269 - pub user_id: String,
270 - /// Whether the current viewer is looking at their own profile.
271 - pub is_own_profile: bool,
272 - /// Whether the current viewer is following this user.
273 - pub is_following: bool,
274 - /// Total follower count for this user.
275 - pub follower_count: i64,
276 - /// Base URL for OG meta tags.
277 - pub host_url: Arc<str>,
278 - /// Whether this creator has voluntarily paused their account.
279 - pub creator_paused: bool,
280 - /// Whether this creator accepts tips.
281 - pub tips_enabled: bool,
282 - /// Creator's user ID for tip checkout (string for template use).
283 - pub creator_id: String,
284 - /// Project ID for tip attribution (None on user profile pages).
285 - pub tip_project_id: Option<String>,
286 - /// Pre-rendered primitive-layer CSS for this creator's chosen theme,
287 - /// injected into the page `<head>` (Tier 0). See `crate::theming`.
288 - pub theme_css: &'static str,
289 - }
290 -
291 257 /// Public project page with item listing.
292 258 #[derive(Template)]
293 259 #[template(path = "pages/project.html")]