max / makenotwork
13 files changed,
+212 insertions,
-39 deletions
| @@ -84,7 +84,7 @@ Filters apply to the Discover page, search results, and tag browsing. The tier i | |||
| 84 | 84 | The tier system is self-reported by creators. We enforce it through: | |
| 85 | 85 | ||
| 86 | 86 | 1. **The disclosure statement.** Assisted-tier creators put their AI use in writing. If the product contradicts the disclosure, that is documented misrepresentation. | |
| 87 | - | 2. **Community reports.** Fans and fellow creators can flag items they believe are misclassified. | |
| 87 | + | 2. **Community reports.** Fans and fellow creators can flag items they believe are misclassified by emailing [reports@makenot.work](mailto:reports@makenot.work) with the item URL and what they think the correct tier should be. An in-app report flow is on the roadmap. | |
| 88 | 88 | 3. **Moderation review.** Misrepresenting your tier is treated as fraud. Consequences follow the [Acceptable Use Policy](../legal/acceptable-use.md), up to and including account termination. | |
| 89 | 89 | ||
| 90 | 90 | If you used AI, say so, explain how, and let your audience decide. That is the point of the Assisted tier. |
| @@ -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 @@ pub struct DiscoverFilters<'a> { | |||
| 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 @@ fn append_item_discover_filters( | |||
| 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 @@ macro_rules! bind_item_discover_filters { | |||
| 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 @@ pub async fn discover_items( | |||
| 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 @@ pub async fn discover_items( | |||
| 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 @@ pub async fn discover_items( | |||
| 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 @@ pub async fn discover_items( | |||
| 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 @@ mod tests { | |||
| 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 @@ impl AiTier { | |||
| 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 @@ pub async fn get_followed_feed_items( | |||
| 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 |
| @@ -37,6 +37,8 @@ pub struct DbDiscoverItemRow { | |||
| 37 | 37 | pub pwyw_min_cents: Option<i32>, | |
| 38 | 38 | /// Trigram similarity score (set when searching, `None` otherwise). | |
| 39 | 39 | pub match_score: Option<f32>, | |
| 40 | + | /// AI disclosure tier — drives the badge / row label on Discover. | |
| 41 | + | pub ai_tier: super::super::AiTier, | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 44 | /// A flattened project row returned by the discover projects query. |
| @@ -95,7 +95,7 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis | |||
| 95 | 95 | ||
| 96 | 96 | let category_filter = query.category.as_deref().filter(|s| !s.is_empty()); | |
| 97 | 97 | ||
| 98 | - | let ai_tier_filter: Option<db::AiTier> = query.ai_tier.as_deref() | |
| 98 | + | let ai_tier_filter: Option<db::AiTierFilter> = query.ai_tier.as_deref() | |
| 99 | 99 | .filter(|s| !s.is_empty()) | |
| 100 | 100 | .and_then(|s| s.parse().ok()); | |
| 101 | 101 | ||
| @@ -399,31 +399,46 @@ pub(super) async fn discover( | |||
| 399 | 399 | }); | |
| 400 | 400 | } | |
| 401 | 401 | ||
| 402 | + | // Per `about/generative-ai.md` § "How Fans Use This", the three | |
| 403 | + | // filter options are "Everything" / "Human-led" (Handmade ∪ | |
| 404 | + | // Assisted) / "Handmade only". Aggregate the per-tier counts | |
| 405 | + | // into option-sized counts before passing to the template. | |
| 406 | + | let mut handmade_count: u32 = 0; | |
| 407 | + | let mut assisted_count: u32 = 0; | |
| 408 | + | for ac in &ai_counts { | |
| 409 | + | match ac.category.as_str() { | |
| 410 | + | "handmade" => handmade_count = ac.count as u32, | |
| 411 | + | "assisted" => assisted_count = ac.count as u32, | |
| 412 | + | _ => {} | |
| 413 | + | } | |
| 414 | + | } | |
| 402 | 415 | let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty()); | |
| 403 | - | let mut ai_tier_filters: Vec<FilterCategory> = vec![FilterCategory { | |
| 404 | - | name: "All".to_string(), | |
| 405 | - | value: String::new(), | |
| 406 | - | count: data.total_count, | |
| 407 | - | active: ai_tier_filter_str.is_none(), | |
| 408 | - | id: String::new(), | |
| 409 | - | following: false, | |
| 410 | - | }]; | |
| 411 | - | for ac in ai_counts { | |
| 412 | - | let label = match ac.category.as_str() { | |
| 413 | - | "handmade" => "Handmade", | |
| 414 | - | "assisted" => "Assisted", | |
| 415 | - | "generated" => "Generated", | |
| 416 | - | other => other, | |
| 417 | - | }; | |
| 418 | - | ai_tier_filters.push(FilterCategory { | |
| 419 | - | value: ac.category.clone(), | |
| 420 | - | name: label.to_string(), | |
| 421 | - | count: ac.count as u32, | |
| 422 | - | active: ai_tier_filter_str == Some(ac.category.as_str()), | |
| 416 | + | let ai_tier_filters: Vec<FilterCategory> = vec![ | |
| 417 | + | FilterCategory { | |
| 418 | + | name: "Everything".to_string(), | |
| 419 | + | value: String::new(), | |
| 420 | + | count: data.total_count, | |
| 421 | + | active: ai_tier_filter_str.is_none(), | |
| 423 | 422 | id: String::new(), | |
| 424 | 423 | following: false, | |
| 425 | - | }); | |
| 426 | - | } | |
| 424 | + | }, | |
| 425 | + | FilterCategory { | |
| 426 | + | name: db::AiTierFilter::HumanLed.label().to_string(), | |
| 427 | + | value: db::AiTierFilter::HumanLed.to_string(), | |
| 428 | + | count: handmade_count + assisted_count, | |
| 429 | + | active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()), | |
| 430 | + | id: String::new(), | |
| 431 | + | following: false, | |
| 432 | + | }, | |
| 433 | + | FilterCategory { | |
| 434 | + | name: db::AiTierFilter::HandmadeOnly.label().to_string(), | |
| 435 | + | value: db::AiTierFilter::HandmadeOnly.to_string(), | |
| 436 | + | count: handmade_count, | |
| 437 | + | active: ai_tier_filter_str == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()), | |
| 438 | + | id: String::new(), | |
| 439 | + | following: false, | |
| 440 | + | }, | |
| 441 | + | ]; | |
| 427 | 442 | ||
| 428 | 443 | (type_filters, tag_filters, ai_tier_filters, price_counts) | |
| 429 | 444 | } else { |
| @@ -77,6 +77,7 @@ impl From<db::DbDiscoverItemRow> for DiscoverItem { | |||
| 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 | } |
| @@ -15,6 +15,10 @@ pub struct DiscoverItem { | |||
| 15 | 15 | pub is_free: bool, | |
| 16 | 16 | pub sales: u32, | |
| 17 | 17 | pub date: String, | |
| 18 | + | /// AI disclosure tier — surfaces as plain text on the discover row. | |
| 19 | + | /// Stored as the snake_case string form so the template can drop it | |
| 20 | + | /// verbatim without needing to know about the daemon's enum type. | |
| 21 | + | pub ai_tier: String, | |
| 18 | 22 | } | |
| 19 | 23 | ||
| 20 | 24 | /// Project in the discover list (projects mode) |
| @@ -1025,6 +1025,58 @@ form button:active { | |||
| 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 | =========================================== */ |
| @@ -95,6 +95,12 @@ | |||
| 95 | 95 | ||
| 96 | 96 | <div class="item-price">{{ item.price }}</div> | |
| 97 | 97 | ||
| 98 | + | {# AI disclosure tier — visible before purchase per | |
| 99 | + | site-docs/public/about/generative-ai.md. #} | |
| 100 | + | <div class="item-ai-tier"> | |
| 101 | + | <span class="badge ai-tier ai-tier-{{ item.ai_tier }}">{{ item.ai_tier.label() }}</span> | |
| 102 | + | </div> | |
| 103 | + | ||
| 98 | 104 | <div class="item-meta"> | |
| 99 | 105 | <div class="meta-item"> | |
| 100 | 106 | <div class="meta-label">Type</div> | |
| @@ -128,6 +134,18 @@ | |||
| 128 | 134 | {% endif %} | |
| 129 | 135 | </div> | |
| 130 | 136 | {% else %} | |
| 137 | + | {# Assisted items disclose AI use above the buy CTA so | |
| 138 | + | fans see it before purchase. Handmade and Generated | |
| 139 | + | carry the badge above; only Assisted owes prose. #} | |
| 140 | + | {% if let crate::db::AiTier::Assisted = item.ai_tier %} | |
| 141 | + | {% if let Some(disclosure) = item.ai_disclosure.as_ref() %} | |
| 142 | + | <div class="ai-disclosure"> | |
| 143 | + | <div class="ai-disclosure-label">AI use</div> | |
| 144 | + | <div class="ai-disclosure-text">{{ disclosure }}</div> | |
| 145 | + | </div> | |
| 146 | + | {% endif %} | |
| 147 | + | {% endif %} | |
| 148 | + | ||
| 131 | 149 | <div class="purchase-box card-muted"> | |
| 132 | 150 | <h3>What's included:</h3> | |
| 133 | 151 | <ul> |
| @@ -25,6 +25,7 @@ | |||
| 25 | 25 | <span class="row-category">{{ item.primary_tag }}</span> | |
| 26 | 26 | <span class="row-price">{{ item.price }}</span> | |
| 27 | 27 | <span class="row-date">{{ item.date }}</span> | |
| 28 | + | <span class="discover-row-ai-tier">{{ item.ai_tier }}</span> | |
| 28 | 29 | </a> | |
| 29 | 30 | {% if is_authenticated %} | |
| 30 | 31 | <button class="row-save collection-picker-anchor" data-collection-trigger data-item-id="{{ item.id }}" | |
| @@ -77,6 +78,7 @@ | |||
| 77 | 78 | <div class="grid-card-footer"> | |
| 78 | 79 | <span class="grid-card-category">{{ item.primary_tag }}</span> | |
| 79 | 80 | <span class="grid-card-price">{{ item.price }}</span> | |
| 81 | + | <span class="discover-row-ai-tier">{{ item.ai_tier }}</span> | |
| 80 | 82 | </div> | |
| 81 | 83 | </div> | |
| 82 | 84 | </a> |
| @@ -61,17 +61,17 @@ | |||
| 61 | 61 | <div class="hint wizard-hint-gap-sm">How was AI used in creating this project's content?</div> | |
| 62 | 62 | <div class="wizard-radio-group"> | |
| 63 | 63 | <label class="wizard-radio-option"> | |
| 64 | - | <input type="radio" name="ai_tier" value="handmade" | |
| 65 | - | {% if ai_tier == "handmade" || ai_tier == "" %}checked{% endif %}> | |
| 64 | + | <input type="radio" name="ai_tier" value="handmade" required | |
| 65 | + | {% if ai_tier == "handmade" %}checked{% endif %}> | |
| 66 | 66 | <span><strong>Handmade</strong> — no AI tools used in content creation</span> | |
| 67 | 67 | </label> | |
| 68 | 68 | <label class="wizard-radio-option"> | |
| 69 | - | <input type="radio" name="ai_tier" value="assisted" | |
| 69 | + | <input type="radio" name="ai_tier" value="assisted" required | |
| 70 | 70 | {% if ai_tier == "assisted" %}checked{% endif %}> | |
| 71 | 71 | <span><strong>AI-Assisted</strong> — AI tools used as part of the creative process</span> | |
| 72 | 72 | </label> | |
| 73 | 73 | <label class="wizard-radio-option"> | |
| 74 | - | <input type="radio" name="ai_tier" value="generated" | |
| 74 | + | <input type="radio" name="ai_tier" value="generated" required | |
| 75 | 75 | {% if ai_tier == "generated" %}checked{% endif %}> | |
| 76 | 76 | <span><strong>AI-Generated</strong> — content primarily generated by AI</span> | |
| 77 | 77 | </label> |
| @@ -81,15 +81,15 @@ | |||
| 81 | 81 | <div class="hint wizard-hint-gap-sm">How was AI used in creating this project's content?</div> | |
| 82 | 82 | <div class="wizard-radio-group"> | |
| 83 | 83 | <label class="wizard-radio-option"> | |
| 84 | - | <input type="radio" name="ai_tier" value="handmade" checked> | |
| 84 | + | <input type="radio" name="ai_tier" value="handmade" required> | |
| 85 | 85 | <span><strong>Handmade</strong> — no AI tools used in content creation</span> | |
| 86 | 86 | </label> | |
| 87 | 87 | <label class="wizard-radio-option"> | |
| 88 | - | <input type="radio" name="ai_tier" value="assisted"> | |
| 88 | + | <input type="radio" name="ai_tier" value="assisted" required> | |
| 89 | 89 | <span><strong>AI-Assisted</strong> — AI tools used as part of the creative process</span> | |
| 90 | 90 | </label> | |
| 91 | 91 | <label class="wizard-radio-option"> | |
| 92 | - | <input type="radio" name="ai_tier" value="generated"> | |
| 92 | + | <input type="radio" name="ai_tier" value="generated" required> | |
| 93 | 93 | <span><strong>AI-Generated</strong> — content primarily generated by AI</span> | |
| 94 | 94 | </label> | |
| 95 | 95 | </div> |