diff --git a/pages/1_what_are_we_building.py b/pages/1_what_are_we_building.py
new file mode 100644
index 000000000000..1495ecd13813
--- /dev/null
+++ b/pages/1_what_are_we_building.py
@@ -0,0 +1,151 @@
+"""
+Step 1: What are we building?
+The plain-language project description.
+"""
+import streamlit as st
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.state import init_state, get_project, update_project, mark_step_complete
+from utils.content import STEP_EXPLANATIONS, ERROR_MESSAGES, SUCCESS_MESSAGES
+
+st.set_page_config(
+ page_title="what are we building? | fullshtack",
+ page_icon="",
+ layout="wide"
+)
+
+# Initialize
+init_state()
+project = get_project()
+step_content = STEP_EXPLANATIONS["project"]
+
+# Header
+st.markdown(f"## {step_content['title']}")
+st.markdown(f"*{step_content['subtitle']}*")
+
+# Two column layout - form on left, explanation on right
+col1, col2 = st.columns([3, 2])
+
+with col1:
+ st.markdown(step_content["explanation"])
+
+ st.markdown("---")
+
+ # Project name
+ project_name = st.text_input(
+ "what should we call this thing?",
+ value=project.project_name,
+ placeholder="my awesome project, the thing, literally anything",
+ help="working title. you can change it later. probably will."
+ )
+
+ # Project description
+ project_description = st.text_area(
+ "what does it do? (explain it like you're telling a friend)",
+ value=project.project_description,
+ placeholder="It's a tool that helps people track their... / It's a site where users can browse and... / It connects people who have X with people who need Y...",
+ height=150,
+ help="don't overthink it. we just need to understand what we're building."
+ )
+
+ # Project type
+ st.markdown("what kind of thing is this, roughly?")
+ project_type = st.radio(
+ "project type",
+ options=["app", "site", "tool", "marketplace", "dashboard", "other"],
+ horizontal=True,
+ index=["app", "site", "tool", "marketplace", "dashboard", "other"].index(project.project_type) if project.project_type else 0,
+ label_visibility="collapsed",
+ help="this helps us suggest sensible defaults later"
+ )
+
+ st.markdown("---")
+
+ # Framework choice with plain explanation
+ st.markdown("### quick tech decision")
+ st.markdown("what should we build this with? (don't stress, they're all good)")
+
+ framework = st.selectbox(
+ "framework",
+ options=["nextjs", "sveltekit", "astro"],
+ index=["nextjs", "sveltekit", "astro"].index(project.framework) if project.framework else 0,
+ format_func=lambda x: {
+ "nextjs": "Next.js - React-based, most popular, tons of resources",
+ "sveltekit": "SvelteKit - simpler syntax, less boilerplate, gaining momentum",
+ "astro": "Astro - great for content sites, ships less JavaScript"
+ }[x],
+ label_visibility="collapsed"
+ )
+
+ database = st.selectbox(
+ "database",
+ options=["supabase", "planetscale", "sqlite"],
+ index=["supabase", "planetscale", "sqlite"].index(project.database) if project.database else 0,
+ format_func=lambda x: {
+ "supabase": "Supabase - Postgres with auth built in, generous free tier",
+ "planetscale": "PlanetScale - MySQL, scales well, branching like git",
+ "sqlite": "SQLite - simple file-based, good for smaller projects"
+ }[x],
+ label_visibility="collapsed"
+ )
+
+ st.markdown("---")
+
+ # Validation and save
+ col_back, col_spacer, col_next = st.columns([1, 2, 1])
+
+ with col_back:
+ if st.button(" back to start", use_container_width=True):
+ st.switch_page("streamlit_app.py")
+
+ with col_next:
+ if st.button("next: the nouns ", type="primary", use_container_width=True):
+ # Validate
+ if not project_name.strip():
+ st.error(ERROR_MESSAGES["empty_name"])
+ elif not project_description.strip():
+ st.error(ERROR_MESSAGES["empty_description"])
+ else:
+ # Save
+ update_project(
+ project_name=project_name.strip(),
+ project_description=project_description.strip(),
+ project_type=project_type,
+ framework=framework,
+ database=database
+ )
+ mark_step_complete(0)
+ st.success(SUCCESS_MESSAGES["project_saved"])
+ st.switch_page("pages/2_what_are_the_nouns.py")
+
+with col2:
+ st.markdown("### why this matters")
+ st.markdown(step_content["why_this_matters"])
+
+ st.markdown("---")
+
+ st.markdown("### the tech translation")
+ st.markdown("""
+ What we're doing here in nerd speak:
+
+ - **Project name** → your app's identifier
+ - **Description** → the README, basically
+ - **Type** → informs architecture decisions
+ - **Framework** → the thing that structures your code
+ - **Database** → where your data lives
+
+ But you don't need to think about it that way.
+ We're just figuring out what we're building.
+ """)
+
+ if project.project_name:
+ st.markdown("---")
+ st.markdown("### current setup")
+ st.code(f"""
+project: {project.project_name}
+type: {project.project_type or 'not set'}
+stack: {project.framework} + {project.database}
+ """, language="yaml")
diff --git a/pages/2_what_are_the_nouns.py b/pages/2_what_are_the_nouns.py
new file mode 100644
index 000000000000..c32741cce394
--- /dev/null
+++ b/pages/2_what_are_the_nouns.py
@@ -0,0 +1,241 @@
+"""
+Step 2: What are the nouns?
+Data modeling without saying "schema".
+"""
+import streamlit as st
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.state import init_state, get_project, update_project, mark_step_complete, Noun
+from utils.content import STEP_EXPLANATIONS, ERROR_MESSAGES, SUCCESS_MESSAGES
+
+st.set_page_config(
+ page_title="what are the nouns? | fullshtack",
+ page_icon="",
+ layout="wide"
+)
+
+# Initialize
+init_state()
+project = get_project()
+step_content = STEP_EXPLANATIONS["nouns"]
+
+# Header
+st.markdown(f"## {step_content['title']}")
+st.markdown(f"*{step_content['subtitle']}*")
+
+# Initialize session state for nouns editing
+if 'editing_nouns' not in st.session_state:
+ st.session_state.editing_nouns = list(project.nouns) if project.nouns else []
+if 'show_add_noun' not in st.session_state:
+ st.session_state.show_add_noun = False
+
+col1, col2 = st.columns([3, 2])
+
+with col1:
+ st.markdown(step_content["explanation"])
+
+ st.markdown("---")
+
+ # Common nouns suggestions based on project type
+ st.markdown("### common nouns (click to add)")
+
+ common_nouns = {
+ "app": ["Users", "Posts", "Comments", "Likes", "Messages", "Notifications"],
+ "site": ["Users", "Pages", "Posts", "Categories", "Tags", "Media"],
+ "tool": ["Users", "Projects", "Tasks", "Settings", "Logs"],
+ "marketplace": ["Users", "Sellers", "Buyers", "Listings", "Orders", "Reviews", "Messages"],
+ "dashboard": ["Users", "Reports", "Metrics", "Settings", "Exports"],
+ "other": ["Users", "Items", "Categories", "Settings"]
+ }
+
+ suggestions = common_nouns.get(project.project_type, common_nouns["other"])
+ existing_names = [n.name.lower() for n in st.session_state.editing_nouns]
+
+ cols = st.columns(4)
+ for i, suggestion in enumerate(suggestions):
+ if suggestion.lower() not in existing_names:
+ with cols[i % 4]:
+ if st.button(f"+ {suggestion}", key=f"add_{suggestion}"):
+ new_noun = Noun(
+ name=suggestion,
+ description=f"The {suggestion.lower()} in your app",
+ fields=[],
+ relationships=[]
+ )
+ st.session_state.editing_nouns.append(new_noun)
+ st.rerun()
+
+ st.markdown("---")
+
+ # Current nouns
+ st.markdown("### your nouns")
+
+ if not st.session_state.editing_nouns:
+ st.info("no nouns yet. add some above or create a custom one below.")
+ else:
+ for i, noun in enumerate(st.session_state.editing_nouns):
+ with st.expander(f"**{noun.name}**", expanded=True):
+ # Basic info
+ new_name = st.text_input(
+ "name",
+ value=noun.name,
+ key=f"noun_name_{i}",
+ label_visibility="collapsed"
+ )
+
+ new_desc = st.text_input(
+ "what is this?",
+ value=noun.description,
+ key=f"noun_desc_{i}",
+ placeholder="a brief description of what this thing is"
+ )
+
+ # Fields
+ st.markdown("**what info do you track about each one?**")
+ st.caption("(these become columns in your database)")
+
+ # Common field suggestions
+ common_fields = {
+ "users": ["email", "name", "password", "avatar", "created_at"],
+ "posts": ["title", "content", "author", "published_at", "status"],
+ "products": ["name", "description", "price", "image", "stock"],
+ "orders": ["user", "items", "total", "status", "created_at"],
+ "messages": ["from", "to", "content", "read", "sent_at"],
+ }
+
+ field_suggestions = common_fields.get(noun.name.lower(), ["name", "description", "created_at"])
+
+ # Show current fields
+ if noun.fields:
+ for j, field in enumerate(noun.fields):
+ fcol1, fcol2, fcol3 = st.columns([2, 2, 1])
+ with fcol1:
+ st.text(field.get("name", ""))
+ with fcol2:
+ st.text(field.get("type", "text"))
+ with fcol3:
+ if st.button("x", key=f"remove_field_{i}_{j}"):
+ noun.fields.pop(j)
+ st.rerun()
+
+ # Add field
+ fcol1, fcol2, fcol3 = st.columns([2, 2, 1])
+ with fcol1:
+ new_field_name = st.text_input(
+ "field name",
+ key=f"new_field_name_{i}",
+ placeholder="field name",
+ label_visibility="collapsed"
+ )
+ with fcol2:
+ new_field_type = st.selectbox(
+ "type",
+ options=["text", "number", "email", "password", "date", "boolean", "reference"],
+ key=f"new_field_type_{i}",
+ label_visibility="collapsed"
+ )
+ with fcol3:
+ if st.button("add", key=f"add_field_{i}"):
+ if new_field_name:
+ noun.fields.append({"name": new_field_name, "type": new_field_type})
+ st.rerun()
+
+ # Quick add common fields
+ st.caption("quick add:")
+ qcols = st.columns(len(field_suggestions))
+ for k, fs in enumerate(field_suggestions):
+ existing_field_names = [f["name"] for f in noun.fields]
+ if fs not in existing_field_names:
+ with qcols[k]:
+ if st.button(fs, key=f"quick_{i}_{fs}"):
+ field_type = "email" if "email" in fs else "password" if "password" in fs else "date" if "_at" in fs else "text"
+ noun.fields.append({"name": fs, "type": field_type})
+ st.rerun()
+
+ st.markdown("---")
+
+ # Update the noun
+ st.session_state.editing_nouns[i].name = new_name
+ st.session_state.editing_nouns[i].description = new_desc
+
+ # Remove button
+ if st.button(f"remove {noun.name}", key=f"remove_noun_{i}", type="secondary"):
+ st.session_state.editing_nouns.pop(i)
+ st.rerun()
+
+ st.markdown("---")
+
+ # Add custom noun
+ st.markdown("### add a custom noun")
+
+ with st.expander("create new noun"):
+ custom_name = st.text_input("what's the thing called?", key="custom_noun_name")
+ custom_desc = st.text_input("what is it?", key="custom_noun_desc", placeholder="a brief description")
+
+ if st.button("add this noun"):
+ if custom_name:
+ existing_names = [n.name.lower() for n in st.session_state.editing_nouns]
+ if custom_name.lower() in existing_names:
+ st.error(ERROR_MESSAGES["duplicate_noun"])
+ else:
+ new_noun = Noun(
+ name=custom_name,
+ description=custom_desc or f"The {custom_name.lower()} in your app",
+ fields=[],
+ relationships=[]
+ )
+ st.session_state.editing_nouns.append(new_noun)
+ st.rerun()
+
+ st.markdown("---")
+
+ # Navigation
+ col_back, col_spacer, col_next = st.columns([1, 2, 1])
+
+ with col_back:
+ if st.button(" back", use_container_width=True):
+ st.switch_page("pages/1_what_are_we_building.py")
+
+ with col_next:
+ if st.button("next: authentication ", type="primary", use_container_width=True):
+ if not st.session_state.editing_nouns:
+ st.error(ERROR_MESSAGES["no_nouns"])
+ else:
+ update_project(nouns=st.session_state.editing_nouns)
+ mark_step_complete(1)
+ st.success(SUCCESS_MESSAGES["nouns_saved"])
+ st.switch_page("pages/3_how_do_people_get_in.py")
+
+with col2:
+ st.markdown("### why this matters")
+ st.markdown(step_content["why_this_matters"])
+
+ st.markdown("---")
+
+ st.markdown("### the tech translation")
+ st.markdown("""
+ What we're doing here in nerd speak:
+
+ - **Nouns** → database tables / models / entities
+ - **Fields** → columns in those tables
+ - **Types** → data types (string, integer, boolean, etc.)
+ - **Relationships** → foreign keys, joins
+
+ The fancy term is "data modeling" or "schema design".
+
+ But really: we're just listing the things and what we know about them.
+ """)
+
+ if st.session_state.editing_nouns:
+ st.markdown("---")
+ st.markdown("### your data model")
+ for noun in st.session_state.editing_nouns:
+ st.markdown(f"**{noun.name}**")
+ if noun.fields:
+ for field in noun.fields:
+ st.caption(f" - {field['name']} ({field['type']})")
+ else:
+ st.caption(" - (no fields yet)")
diff --git a/pages/3_how_do_people_get_in.py b/pages/3_how_do_people_get_in.py
new file mode 100644
index 000000000000..db0bf7d3be54
--- /dev/null
+++ b/pages/3_how_do_people_get_in.py
@@ -0,0 +1,208 @@
+"""
+Step 3: How do people get in?
+Authentication without the jargon.
+"""
+import streamlit as st
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.state import init_state, get_project, update_project, mark_step_complete
+from utils.content import STEP_EXPLANATIONS, SUCCESS_MESSAGES
+
+st.set_page_config(
+ page_title="how do people get in? | fullshtack",
+ page_icon="",
+ layout="wide"
+)
+
+# Initialize
+init_state()
+project = get_project()
+step_content = STEP_EXPLANATIONS["auth"]
+
+# Header
+st.markdown(f"## {step_content['title']}")
+st.markdown(f"*{step_content['subtitle']}*")
+
+col1, col2 = st.columns([3, 2])
+
+with col1:
+ st.markdown(step_content["explanation"])
+
+ st.markdown("---")
+
+ # Auth method selection
+ st.markdown("### pick your login method")
+
+ auth_options = {
+ "email": {
+ "label": "email & password",
+ "description": "classic. they sign up with email, pick a password, log in with those.",
+ "icon": ""
+ },
+ "google": {
+ "label": "google / social login",
+ "description": "one click with their Google account. less friction, but you're dependent on Google.",
+ "icon": ""
+ },
+ "both": {
+ "label": "both options",
+ "description": "let them choose. more work to set up, but maximum flexibility.",
+ "icon": ""
+ },
+ "none": {
+ "label": "no login needed",
+ "description": "public tool, no user accounts. everyone sees the same thing.",
+ "icon": ""
+ }
+ }
+
+ # Current selection
+ current_auth = project.auth_method or "email"
+
+ for method, info in auth_options.items():
+ is_selected = current_auth == method
+ container = st.container()
+ with container:
+ col_radio, col_text = st.columns([1, 10])
+ with col_radio:
+ if st.checkbox(
+ info["label"],
+ value=is_selected,
+ key=f"auth_{method}",
+ label_visibility="collapsed"
+ ):
+ current_auth = method
+ with col_text:
+ st.markdown(f"**{info['icon']} {info['label']}**")
+ st.caption(info["description"])
+
+ st.markdown("---")
+
+ # Additional features (only if auth is needed)
+ if current_auth != "none":
+ st.markdown("### extra login features")
+ st.caption("check what you want. we'll set it up.")
+
+ features = {
+ "password_reset": "forgot password / reset by email",
+ "remember_me": "stay logged in (remember me checkbox)",
+ "email_verify": "verify email before account works",
+ "magic_link": "passwordless login via email link",
+ "two_factor": "two-factor authentication (SMS or app)"
+ }
+
+ selected_features = project.auth_features or []
+
+ for feature_key, feature_label in features.items():
+ is_checked = feature_key in selected_features
+ if st.checkbox(feature_label, value=is_checked, key=f"feature_{feature_key}"):
+ if feature_key not in selected_features:
+ selected_features.append(feature_key)
+ else:
+ if feature_key in selected_features:
+ selected_features.remove(feature_key)
+
+ st.markdown("---")
+
+ # Visual representation of what this means
+ st.markdown("### what this looks like in practice")
+
+ preview_col1, preview_col2 = st.columns(2)
+
+ with preview_col1:
+ st.markdown("**sign up page will have:**")
+ if current_auth in ["email", "both"]:
+ st.markdown("- email field")
+ st.markdown("- password field")
+ st.markdown("- confirm password field")
+ if current_auth in ["google", "both"]:
+ st.markdown("- 'continue with Google' button")
+ if "email_verify" in selected_features:
+ st.markdown("- sends verification email after signup")
+
+ with preview_col2:
+ st.markdown("**login page will have:**")
+ if current_auth in ["email", "both"]:
+ st.markdown("- email field")
+ st.markdown("- password field")
+ if current_auth in ["google", "both"]:
+ st.markdown("- 'continue with Google' button")
+ if "remember_me" in selected_features:
+ st.markdown("- 'remember me' checkbox")
+ if "password_reset" in selected_features:
+ st.markdown("- 'forgot password?' link")
+ if "magic_link" in selected_features:
+ st.markdown("- 'email me a login link' option")
+
+ else:
+ selected_features = []
+ st.info("no login means everyone can access everything. make sure that's what you want.")
+
+ st.markdown("---")
+
+ # Navigation
+ col_back, col_spacer, col_next = st.columns([1, 2, 1])
+
+ with col_back:
+ if st.button(" back", use_container_width=True):
+ st.switch_page("pages/2_what_are_the_nouns.py")
+
+ with col_next:
+ if st.button("next: pages ", type="primary", use_container_width=True):
+ update_project(
+ auth_method=current_auth,
+ auth_features=selected_features
+ )
+ mark_step_complete(2)
+ st.success(SUCCESS_MESSAGES["auth_saved"])
+ st.switch_page("pages/4_what_pages_exist.py")
+
+with col2:
+ st.markdown("### why this matters")
+ st.markdown(step_content["why_this_matters"])
+
+ st.markdown("---")
+
+ st.markdown("### the tech translation")
+ st.markdown("""
+ What we're doing here in nerd speak:
+
+ - **Authentication** → proving who someone is
+ - **Email/password** → credential-based auth
+ - **Google login** → OAuth 2.0 flow
+ - **Remember me** → persistent session/token
+ - **Password reset** → token-based password recovery
+ - **Email verify** → email confirmation flow
+ - **Magic link** → passwordless auth via email
+ - **2FA** → multi-factor authentication
+
+ Your database setup (**{database}**) handles most of this for you.
+ We're just deciding what options to turn on.
+ """.format(database=project.database))
+
+ st.markdown("---")
+
+ st.markdown("### security note")
+ st.markdown("""
+ We're not rolling our own auth. That's how security holes happen.
+
+ Your stack ({database}) has battle-tested auth built in.
+ We're using that. You just pick the features.
+
+ - Passwords get hashed (unreadable even to us)
+ - Sessions are secure
+ - Tokens expire properly
+ - All the boring-but-critical stuff is handled
+ """.format(database=project.database))
+
+ if current_auth != "none":
+ st.markdown("---")
+ st.markdown("### your auth setup")
+ st.code(f"""
+method: {current_auth}
+features:
+{chr(10).join(f' - {f}' for f in selected_features) if selected_features else ' (none selected)'}
+ """, language="yaml")
diff --git a/pages/4_what_pages_exist.py b/pages/4_what_pages_exist.py
new file mode 100644
index 000000000000..6f94681d04f8
--- /dev/null
+++ b/pages/4_what_pages_exist.py
@@ -0,0 +1,259 @@
+"""
+Step 4: What pages exist?
+Routing without the routing jargon.
+"""
+import streamlit as st
+import sys
+from pathlib import Path
+import re
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.state import init_state, get_project, update_project, mark_step_complete, Page
+from utils.content import STEP_EXPLANATIONS, ERROR_MESSAGES, SUCCESS_MESSAGES
+
+st.set_page_config(
+ page_title="what pages exist? | fullshtack",
+ page_icon="",
+ layout="wide"
+)
+
+# Initialize
+init_state()
+project = get_project()
+step_content = STEP_EXPLANATIONS["pages"]
+
+# Header
+st.markdown(f"## {step_content['title']}")
+st.markdown(f"*{step_content['subtitle']}*")
+
+# Initialize session state for pages
+if 'editing_pages' not in st.session_state:
+ st.session_state.editing_pages = list(project.pages) if project.pages else []
+
+col1, col2 = st.columns([3, 2])
+
+with col1:
+ st.markdown(step_content["explanation"])
+
+ st.markdown("---")
+
+ # Common page suggestions based on project type and auth
+ st.markdown("### common pages (click to add)")
+
+ base_pages = ["Home", "About"]
+
+ auth_pages = []
+ if project.auth_method and project.auth_method != "none":
+ auth_pages = ["Sign Up", "Log In", "Profile", "Settings"]
+
+ # Pages based on nouns
+ noun_pages = []
+ for noun in project.nouns:
+ noun_pages.append(f"{noun.name} List")
+ noun_pages.append(f"{noun.name} Detail")
+ if project.auth_method != "none":
+ noun_pages.append(f"Create {noun.name}")
+
+ all_suggestions = base_pages + auth_pages + noun_pages
+ existing_names = [p.name.lower() for p in st.session_state.editing_pages]
+
+ cols = st.columns(3)
+ for i, suggestion in enumerate(all_suggestions[:12]): # Limit to 12
+ if suggestion.lower() not in existing_names:
+ with cols[i % 3]:
+ if st.button(f"+ {suggestion}", key=f"add_page_{suggestion}"):
+ # Generate path from name
+ path = "/" + re.sub(r'[^a-z0-9]+', '-', suggestion.lower()).strip('-')
+ if path == "/home":
+ path = "/"
+
+ # Determine if auth required
+ requires_auth = suggestion in ["Profile", "Settings"] or "Create" in suggestion
+
+ # Connect to nouns
+ connected = []
+ for noun in project.nouns:
+ if noun.name.lower() in suggestion.lower():
+ connected.append(noun.name)
+
+ new_page = Page(
+ name=suggestion,
+ path=path,
+ description=f"The {suggestion.lower()} page",
+ requires_auth=requires_auth,
+ connected_nouns=connected
+ )
+ st.session_state.editing_pages.append(new_page)
+ st.rerun()
+
+ st.markdown("---")
+
+ # Current pages
+ st.markdown("### your pages")
+
+ if not st.session_state.editing_pages:
+ st.info("no pages yet. add some above or create a custom one below.")
+ else:
+ for i, page in enumerate(st.session_state.editing_pages):
+ with st.expander(f"**{page.name}** `{page.path}`", expanded=False):
+ # Page name
+ new_name = st.text_input(
+ "page name",
+ value=page.name,
+ key=f"page_name_{i}"
+ )
+
+ # Path
+ new_path = st.text_input(
+ "url path",
+ value=page.path,
+ key=f"page_path_{i}",
+ help="like /about or /users/123"
+ )
+
+ # Description
+ new_desc = st.text_input(
+ "what's on this page?",
+ value=page.description,
+ key=f"page_desc_{i}"
+ )
+
+ # Requires auth?
+ if project.auth_method != "none":
+ new_requires_auth = st.checkbox(
+ "requires login to see?",
+ value=page.requires_auth,
+ key=f"page_auth_{i}"
+ )
+ else:
+ new_requires_auth = False
+
+ # Connected nouns
+ if project.nouns:
+ st.markdown("**which nouns show up here?**")
+ new_connected = []
+ noun_cols = st.columns(len(project.nouns))
+ for j, noun in enumerate(project.nouns):
+ with noun_cols[j]:
+ if st.checkbox(
+ noun.name,
+ value=noun.name in page.connected_nouns,
+ key=f"page_noun_{i}_{j}"
+ ):
+ new_connected.append(noun.name)
+ else:
+ new_connected = []
+
+ # Update
+ st.session_state.editing_pages[i].name = new_name
+ st.session_state.editing_pages[i].path = new_path
+ st.session_state.editing_pages[i].description = new_desc
+ st.session_state.editing_pages[i].requires_auth = new_requires_auth
+ st.session_state.editing_pages[i].connected_nouns = new_connected
+
+ st.markdown("---")
+
+ if st.button(f"remove this page", key=f"remove_page_{i}", type="secondary"):
+ st.session_state.editing_pages.pop(i)
+ st.rerun()
+
+ st.markdown("---")
+
+ # Add custom page
+ st.markdown("### add a custom page")
+
+ with st.expander("create new page"):
+ custom_name = st.text_input("page name", key="custom_page_name")
+ custom_path = st.text_input(
+ "url path",
+ key="custom_page_path",
+ placeholder="/something",
+ help="lowercase, no spaces, start with /"
+ )
+ custom_desc = st.text_input("what's on this page?", key="custom_page_desc")
+
+ custom_auth = False
+ if project.auth_method != "none":
+ custom_auth = st.checkbox("requires login?", key="custom_page_auth")
+
+ custom_nouns = []
+ if project.nouns:
+ st.markdown("**uses which nouns?**")
+ for noun in project.nouns:
+ if st.checkbox(noun.name, key=f"custom_noun_{noun.name}"):
+ custom_nouns.append(noun.name)
+
+ if st.button("add this page"):
+ if custom_name and custom_path:
+ # Validate path
+ if not custom_path.startswith("/") or " " in custom_path:
+ st.error(ERROR_MESSAGES["invalid_path"])
+ else:
+ new_page = Page(
+ name=custom_name,
+ path=custom_path,
+ description=custom_desc or f"The {custom_name.lower()} page",
+ requires_auth=custom_auth,
+ connected_nouns=custom_nouns
+ )
+ st.session_state.editing_pages.append(new_page)
+ st.rerun()
+
+ st.markdown("---")
+
+ # Navigation
+ col_back, col_spacer, col_next = st.columns([1, 2, 1])
+
+ with col_back:
+ if st.button(" back", use_container_width=True):
+ st.switch_page("pages/3_how_do_people_get_in.py")
+
+ with col_next:
+ if st.button("next: styling ", type="primary", use_container_width=True):
+ if not st.session_state.editing_pages:
+ st.error(ERROR_MESSAGES["no_pages"])
+ else:
+ update_project(pages=st.session_state.editing_pages)
+ mark_step_complete(3)
+ st.success(SUCCESS_MESSAGES["pages_saved"])
+ st.switch_page("pages/5_pick_a_vibe.py")
+
+with col2:
+ st.markdown("### why this matters")
+ st.markdown(step_content["why_this_matters"])
+
+ st.markdown("---")
+
+ st.markdown("### the tech translation")
+ st.markdown("""
+ What we're doing here in nerd speak:
+
+ - **Pages** → routes in your framework
+ - **Path** → the URL pattern (`/users`, `/posts/[id]`)
+ - **Requires login** → protected routes / auth guards
+ - **Connected nouns** → which data this page fetches
+
+ Your framework ({framework}) handles the actual routing.
+ We're just mapping out what exists.
+ """.format(framework=project.framework))
+
+ if st.session_state.editing_pages:
+ st.markdown("---")
+ st.markdown("### your sitemap")
+
+ for page in st.session_state.editing_pages:
+ auth_badge = " (login)" if page.requires_auth else ""
+ nouns_badge = f" [{', '.join(page.connected_nouns)}]" if page.connected_nouns else ""
+ st.markdown(f"`{page.path}` - {page.name}{auth_badge}{nouns_badge}")
+
+ # Visual tree
+ st.markdown("---")
+ st.markdown("### route tree")
+ routes = [p.path for p in st.session_state.editing_pages]
+ routes.sort()
+ for route in routes:
+ depth = route.count("/") - 1 if route != "/" else 0
+ indent = " " * depth
+ name = route.split("/")[-1] or "home"
+ st.code(f"{indent}{name}", language=None)
diff --git a/pages/5_pick_a_vibe.py b/pages/5_pick_a_vibe.py
new file mode 100644
index 000000000000..94dd418e0f13
--- /dev/null
+++ b/pages/5_pick_a_vibe.py
@@ -0,0 +1,249 @@
+"""
+Step 5: Pick a vibe.
+Styling without the CSS trauma.
+"""
+import streamlit as st
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.state import init_state, get_project, update_project, mark_step_complete
+from utils.content import STEP_EXPLANATIONS, SUCCESS_MESSAGES
+
+st.set_page_config(
+ page_title="pick a vibe | fullshtack",
+ page_icon="",
+ layout="wide"
+)
+
+# Initialize
+init_state()
+project = get_project()
+step_content = STEP_EXPLANATIONS["styling"]
+
+# Header
+st.markdown(f"## {step_content['title']}")
+st.markdown(f"*{step_content['subtitle']}*")
+
+# Style definitions with visual examples
+VIBES = {
+ "clean": {
+ "name": "clean & corporate",
+ "description": "lots of white space, professional, trustworthy. think: stripe, notion.",
+ "colors": {
+ "primary": "#0066FF",
+ "secondary": "#6B7280",
+ "background": "#FFFFFF",
+ "surface": "#F9FAFB",
+ "text": "#111827",
+ "border": "#E5E7EB"
+ },
+ "border_radius": "8px",
+ "font": "Inter, system-ui, sans-serif",
+ "shadows": True
+ },
+ "friendly": {
+ "name": "friendly & rounded",
+ "description": "softer edges, warmer colors, approachable. think: slack, figma.",
+ "colors": {
+ "primary": "#7C3AED",
+ "secondary": "#EC4899",
+ "background": "#FFFBEB",
+ "surface": "#FEF3C7",
+ "text": "#1F2937",
+ "border": "#FCD34D"
+ },
+ "border_radius": "16px",
+ "font": "Nunito, system-ui, sans-serif",
+ "shadows": True
+ },
+ "minimal": {
+ "name": "minimal & sharp",
+ "description": "less is more, stark contrasts, modern. think: linear, vercel.",
+ "colors": {
+ "primary": "#000000",
+ "secondary": "#666666",
+ "background": "#FFFFFF",
+ "surface": "#FAFAFA",
+ "text": "#000000",
+ "border": "#EEEEEE"
+ },
+ "border_radius": "4px",
+ "font": "SF Mono, monospace",
+ "shadows": False
+ },
+ "brutalist": {
+ "name": "brutalist & weird",
+ "description": "intentionally raw, anti-design. think: are.na, cargo.",
+ "colors": {
+ "primary": "#FF0000",
+ "secondary": "#0000FF",
+ "background": "#FFFFFF",
+ "surface": "#FFFF00",
+ "text": "#000000",
+ "border": "#000000"
+ },
+ "border_radius": "0px",
+ "font": "Times New Roman, serif",
+ "shadows": False
+ }
+}
+
+col1, col2 = st.columns([3, 2])
+
+with col1:
+ st.markdown(step_content["explanation"])
+
+ st.markdown("---")
+
+ # Vibe selection with live preview
+ st.markdown("### pick your vibe")
+
+ current_vibe = project.style_vibe or "clean"
+
+ for vibe_key, vibe_data in VIBES.items():
+ is_selected = current_vibe == vibe_key
+
+ # Create a visual card for each vibe
+ colors = vibe_data["colors"]
+ border = f"3px solid {colors['primary']}" if is_selected else f"1px solid {colors['border']}"
+
+ st.markdown(f"""
+
+
+
+
{vibe_data['name']}
+
+ {vibe_data['description']}
+
+
+
+
+
+
+
+
+
+ this is what a card would look like. notice the corners, the spacing, the overall feel.
+
+
+
+ """, unsafe_allow_html=True)
+
+ if st.button(
+ f"{'selected' if is_selected else 'select'} {vibe_data['name']}",
+ key=f"select_{vibe_key}",
+ type="primary" if is_selected else "secondary",
+ disabled=is_selected
+ ):
+ current_vibe = vibe_key
+ st.rerun()
+
+ st.markdown("---")
+
+ # Color customization
+ st.markdown("### customize the primary color")
+ st.caption("or just keep the default. it's fine.")
+
+ selected_vibe_data = VIBES[current_vibe]
+ default_color = selected_vibe_data["colors"]["primary"]
+
+ primary_color = st.color_picker(
+ "primary color",
+ value=project.primary_color if project.primary_color != "#0068c9" else default_color,
+ label_visibility="collapsed"
+ )
+
+ st.markdown("---")
+
+ # Navigation
+ col_back, col_spacer, col_next = st.columns([1, 2, 1])
+
+ with col_back:
+ if st.button(" back", use_container_width=True):
+ st.switch_page("pages/4_what_pages_exist.py")
+
+ with col_next:
+ if st.button("finish setup ", type="primary", use_container_width=True):
+ update_project(
+ style_vibe=current_vibe,
+ primary_color=primary_color
+ )
+ mark_step_complete(4)
+ st.success(SUCCESS_MESSAGES["all_done"])
+ st.balloons()
+ st.switch_page("pages/7_your_schtack.py")
+
+with col2:
+ st.markdown("### why this matters")
+ st.markdown(step_content["why_this_matters"])
+
+ st.markdown("---")
+
+ st.markdown("### the tech translation")
+ st.markdown("""
+ What we're doing here in nerd speak:
+
+ - **Vibe** → design system / theme
+ - **Colors** → design tokens / CSS variables
+ - **Border radius** → corner rounding
+ - **Font** → typography stack
+ - **Shadows** → elevation / depth
+
+ We'll generate CSS variables from this:
+
+ ```css
+ :root {
+ --color-primary: #...;
+ --color-secondary: #...;
+ --radius: 8px;
+ --font-body: Inter, sans-serif;
+ }
+ ```
+
+ Then every component uses these variables.
+ Change the vibe, everything updates.
+ """)
+
+ st.markdown("---")
+
+ st.markdown("### your style config")
+ vibe_data = VIBES[current_vibe]
+ st.code(f"""
+vibe: {current_vibe}
+primary: {primary_color}
+secondary: {vibe_data['colors']['secondary']}
+border-radius: {vibe_data['border_radius']}
+font: {vibe_data['font'].split(',')[0]}
+shadows: {vibe_data['shadows']}
+ """, language="yaml")
diff --git a/pages/6_component_library.py b/pages/6_component_library.py
new file mode 100644
index 000000000000..b8bb088e202e
--- /dev/null
+++ b/pages/6_component_library.py
@@ -0,0 +1,653 @@
+"""
+Component Library - Functional Legos
+Each block shows what it is, what it needs, and how it connects.
+"""
+import streamlit as st
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.state import init_state, get_project
+from utils.content import COMPONENT_EXPLANATIONS
+
+st.set_page_config(
+ page_title="component library | fullshtack",
+ page_icon="",
+ layout="wide"
+)
+
+# Initialize
+init_state()
+project = get_project()
+
+# Custom CSS for component previews
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+# Header
+st.markdown("## component library")
+st.markdown("*functional legos - each one shows what it is, what it needs, what it connects to*")
+
+st.markdown("""
+These aren't just pretty pictures of UI elements.
+
+Each component here is a **contract**:
+- What data does it expect?
+- What does it do with that data?
+- What happens when things go wrong?
+- How does it talk to other components?
+
+Click any component to see the full picture.
+""")
+
+st.markdown("---")
+
+# Component categories
+COMPONENTS = {
+ "inputs": {
+ "name": "getting input from people",
+ "items": [
+ {
+ "name": "Text Input",
+ "description": "a box where someone types something",
+ "props": ["label", "placeholder", "value", "onChange", "validation"],
+ "dependencies": [],
+ "connects_to": ["Forms", "State"],
+ "preview_html": """
+
+
+
+
+ """,
+ "code_example": """
+// What this looks like in your code
+ setEmail(e.target.value)}
+ validation={{
+ required: true,
+ pattern: /^[^@]+@[^@]+$/,
+ errorMessage: "that doesn't look like an email"
+ }}
+/>
+ """,
+ "gotchas": "Always validate on the server too. The browser can be tricked."
+ },
+ {
+ "name": "Password Input",
+ "description": "like text input but hidden, with optional show/hide toggle",
+ "props": ["label", "value", "onChange", "showToggle", "minLength"],
+ "dependencies": [],
+ "connects_to": ["Forms", "Auth"],
+ "preview_html": """
+
+
+
+
+ """,
+ "code_example": """
+ setPassword(e.target.value)}
+ showToggle={true}
+ minLength={8}
+ requirements={["uppercase", "number"]}
+/>
+ """,
+ "gotchas": "Never store passwords in plain text. Your auth system handles this."
+ },
+ {
+ "name": "Select / Dropdown",
+ "description": "pick one thing from a list of things",
+ "props": ["label", "options", "value", "onChange", "placeholder"],
+ "dependencies": [],
+ "connects_to": ["Forms", "State", "API (for dynamic options)"],
+ "preview_html": """
+
+
+
+
+ """,
+ "code_example": """
+