Skip to main content

max / makenotwork

server: ship AI disclosure surface per generative-ai.md Item page now shows a colored AI-tier pill (Handmade=violet, Assisted= warm-tan, Generated=charcoal — brand palette, deliberately not traffic- light coloring). Assisted items also render the ai_disclosure text above the buy box so fans see it before purchase. Discover row gets the tier as plain mono text (no pill — keeps row density). DbDiscoverItemRow + DiscoverItem thread ai_tier through; the 3 SELECTs in db/discover.rs and the follow-feed SELECT in db/follows.rs all pull i.ai_tier now. Sidebar filter rewrites the discrete-bucket shape into the policy's Everything / Human-led (handmade ∪ assisted) / Handmade only. New AiTierFilter enum encodes the filter as distinct-from-AiTier (since "Human-led" isn't a per-item value). SQL uses inline literal fragments for the WHERE clause — enum-derived, no user input — which freed up bind position $6, so LIMIT/OFFSET shift from $7/$8 to $6/$7. Project wizards drop the pre-checked handmade default and add `required` to all three radios so the creator picks deliberately (matches policy's "no unlabeled option" spirit). Policy: community reports route to reports@makenot.work (email path) for now; in-app /report endpoint stays on the Phase 4 list. Three new SQL builder tests lock the filter literals (handmade_only = single tier, human_led = IN handmade,assisted) and the enum round-trip. 1,661 lib tests passing.
Author: Max Johnson <me@maxj.phd> · 2026-06-04 03:21 UTC
Signed with PGP, not checked
Commit: 0416215dc91bfdec4314290936d509e45a898dcb
Parent: 5123cf2
13 files changed, +212 insertions, -39 deletions
@@ -1025,6 +1025,58 @@
1025 1025 color: var(--primary-light);
1026 1026 }
1027 1027
1028 + /* AI disclosure tier badges. See site-docs/public/about/generative-ai.md.
1029 + Colors track the brand palette — violet for the cleanest case,
1030 + warm-tan for disclosed-AI-use, charcoal for primarily-generated.
1031 + Deliberately not red/yellow/green; this is disclosure, not alarm. */
1032 + .badge.ai-tier {
1033 + letter-spacing: 0.02em;
1034 + }
1035 + .badge.ai-tier-handmade {
1036 + background: var(--highlight);
1037 + color: var(--primary-light);
1038 + }
1039 + .badge.ai-tier-assisted {
1040 + background: var(--warning);
1041 + color: var(--primary-light);
1042 + }
1043 + .badge.ai-tier-generated {
1044 + background: var(--detail);
1045 + color: var(--primary-light);
1046 + }
1047 +
1048 + .item-ai-tier {
1049 + margin: 0.25rem 0 0.75rem;
1050 + }
1051 +
1052 + .ai-disclosure {
1053 + margin: 0.75rem 0;
1054 + padding: 0.75rem 1rem;
1055 + background: var(--surface-muted);
1056 + border-left: 3px solid var(--warning);
1057 + }
1058 + .ai-disclosure-label {
1059 + font-family: var(--font-mono);
1060 + font-size: 0.75rem;
1061 + text-transform: uppercase;
1062 + letter-spacing: 0.05em;
1063 + color: var(--text-muted);
1064 + margin-bottom: 0.25rem;
1065 + }
1066 + .ai-disclosure-text {
1067 + font-size: 0.9rem;
1068 + color: var(--detail);
1069 + }
1070 +
1071 + /* Discover row: plain text mention per design decision 2026-06-03 —
1072 + no pill in the row, so fans skim the listing without disclosure noise
1073 + and use the explicit filter when they care. */
1074 + .discover-row-ai-tier {
1075 + font-family: var(--font-mono);
1076 + font-size: 0.75rem;
1077 + color: var(--text-muted);
1078 + }
1079 +
1028 1080 /* ===========================================
1029 1081 TAGS
1030 1082 =========================================== */
@@ -4,7 +4,7 @@
4 4
5 5 use sqlx::{FromRow, PgPool};
6 6
7 - use super::enums::{AiTier, DiscoverSort, ItemType};
7 + use super::enums::{AiTierFilter, DiscoverSort, ItemType};
8 8 use super::models::*;
9 9 use crate::error::Result;
10 10
@@ -20,7 +20,7 @@
20 20 pub min_price: Option<i32>,
21 21 pub max_price: Option<i32>,
22 22 pub sort_by: Option<DiscoverSort>,
23 - pub ai_tier: Option<AiTier>,
23 + pub ai_tier: Option<AiTierFilter>,
24 24 }
25 25
26 26 // Shared SQL fragments for fuzzy search (trigram + ILIKE fallback).
@@ -113,12 +113,22 @@
113 113 )"#,
114 114 );
115 115 }
116 - if filters.ai_tier.is_some() {
117 - query.push_str(" AND i.ai_tier = $6");
116 + // AI disclosure filter: `Handmade only` narrows to handmade; `Human-led`
117 + // accepts handmade ∪ assisted. Values are enum-derived constants (not user
118 + // input), so inlining the literals is safe and keeps the bind-position
119 + // count stable across queries that use this fragment.
120 + match filters.ai_tier {
121 + Some(AiTierFilter::HandmadeOnly) => query.push_str(" AND i.ai_tier = 'handmade'"),
122 + Some(AiTierFilter::HumanLed) => {
123 + query.push_str(" AND i.ai_tier IN ('handmade', 'assisted')")
124 + }
125 + None => {}
118 126 }
119 127 }
120 128
121 - /// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query.
129 + /// Bind the 5 discover-filter parameters ($1-$5) to a sqlx query.
130 + /// The AI-tier filter is appended to the WHERE as a literal SQL fragment,
131 + /// so it occupies no bind position.
122 132 macro_rules! bind_item_discover_filters {
123 133 ($q:expr, $filters:expr, $search_term:expr) => {
124 134 $q.bind($search_term.unwrap_or(""))
@@ -126,7 +136,6 @@
126 136 .bind($filters.min_price.unwrap_or(0))
127 137 .bind($filters.max_price.unwrap_or(i32::MAX))
128 138 .bind($filters.tag.unwrap_or(""))
129 - .bind($filters.ai_tier.map(|t| t.to_string()).unwrap_or_default())
130 139 };
131 140 }
132 141
@@ -173,6 +182,7 @@
173 182 pt.name as primary_tag_name,
174 183 i.pwyw_enabled,
175 184 i.pwyw_min_cents,
185 + i.ai_tier,
176 186 GREATEST(
177 187 similarity(i.title, $1),
178 188 similarity(COALESCE(i.description, ''), $1) * 0.5
@@ -202,6 +212,7 @@
202 212 pt.name as primary_tag_name,
203 213 i.pwyw_enabled,
204 214 i.pwyw_min_cents,
215 + i.ai_tier,
205 216 1.0::real as match_score
206 217 FROM items i
207 218 JOIN projects p ON i.project_id = p.id
@@ -227,6 +238,7 @@
227 238 pt.name as primary_tag_name,
228 239 i.pwyw_enabled,
229 240 i.pwyw_min_cents,
241 + i.ai_tier,
230 242 NULL::real as match_score
231 243 FROM items i
232 244 JOIN projects p ON i.project_id = p.id
@@ -252,7 +264,7 @@
252 264 }
253 265 };
254 266
255 - query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order));
267 + query.push_str(&format!(" ORDER BY {} LIMIT $6 OFFSET $7", order));
256 268
257 269 let items = bind_item_discover_filters!(
258 270 sqlx::query_as::<_, DbDiscoverItemRow>(&query),
@@ -834,4 +846,45 @@
834 846 append_item_discover_filters(&mut q, &filters, true, false);
835 847 assert!(q.contains("i.title % $1"));
836 848 }
849 +
850 + #[test]
851 + fn append_filters_handmade_only_narrows_to_one_tier() {
852 + let filters = DiscoverFilters {
853 + search: None, item_type: None, tag: None,
854 + min_price: None, max_price: None, sort_by: None,
855 + ai_tier: Some(AiTierFilter::HandmadeOnly),
856 + };
857 + let mut q = String::new();
858 + append_item_discover_filters(&mut q, &filters, false, false);
859 + assert!(q.contains("i.ai_tier = 'handmade'"));
860 + assert!(!q.contains("assisted"));
861 + }
862 +
863 + #[test]
864 + fn append_filters_human_led_includes_handmade_and_assisted() {
865 + // Locks the policy commitment that Human-led covers BOTH handmade
866 + // and assisted. A future rename of the literals or a swap to a
867 + // single-tier match would silently weaken the filter.
868 + let filters = DiscoverFilters {
869 + search: None, item_type: None, tag: None,
870 + min_price: None, max_price: None, sort_by: None,
871 + ai_tier: Some(AiTierFilter::HumanLed),
872 + };
873 + let mut q = String::new();
874 + append_item_discover_filters(&mut q, &filters, false, false);
875 + assert!(q.contains("i.ai_tier IN ('handmade', 'assisted')"));
876 + assert!(!q.contains("generated"));
877 + }
878 +
879 + #[test]
880 + fn ai_tier_filter_round_trip() {
881 + // Parses the query-string value the route receives back into the
882 + // typed enum the SQL builder expects.
883 + assert_eq!("handmade_only".parse::<AiTierFilter>().unwrap(), AiTierFilter::HandmadeOnly);
884 + assert_eq!("human_led".parse::<AiTierFilter>().unwrap(), AiTierFilter::HumanLed);
885 + assert!("everything".parse::<AiTierFilter>().is_err());
886 + assert!("assisted".parse::<AiTierFilter>().is_err());
887 + assert_eq!(AiTierFilter::HumanLed.to_string(), "human_led");
888 + assert_eq!(AiTierFilter::HandmadeOnly.label(), "Handmade only");
889 + }
837 890 }
@@ -591,6 +591,31 @@
591 591 }
592 592 }
593 593
594 + /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
595 + /// Use This". Distinct from `AiTier` because this is a *filter*, not a
596 + /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers.
597 + /// `None` on `DiscoverFilters.ai_tier` means "Everything" — no
598 + /// restriction.
599 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
600 + pub enum AiTierFilter {
601 + HandmadeOnly,
602 + HumanLed,
603 + }
604 +
605 + impl_str_enum!(AiTierFilter {
606 + HandmadeOnly => "handmade_only",
607 + HumanLed => "human_led",
608 + });
609 +
610 + impl AiTierFilter {
611 + pub fn label(&self) -> &'static str {
612 + match self {
613 + Self::HandmadeOnly => "Handmade only",
614 + Self::HumanLed => "Human-led",
615 + }
616 + }
617 + }
618 +
594 619 // ── Project Features ──
595 620
596 621 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -142,7 +142,8 @@
142 142 pt.name as primary_tag_name,
143 143 i.pwyw_enabled,
144 144 i.pwyw_min_cents,
145 - NULL::real as match_score
145 + NULL::real as match_score,
146 + i.ai_tier
146 147 FROM items i
147 148 JOIN projects p ON i.project_id = p.id
148 149 JOIN users u ON p.user_id = u.id
@@ -77,6 +77,7 @@
77 77 is_free: i.price_cents == 0 && !i.pwyw_enabled,
78 78 sales: i.sales_count.clamp(0, u32::MAX as i64) as u32,
79 79 date: i.created_at.format(DATE_FMT_SHORT).to_string(),
80 + ai_tier: i.ai_tier.to_string(),
80 81 }
81 82 }
82 83 }