From 683056dc2ae91005e4f15357fba72f1c4eae74b0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 27 Nov 2025 08:07:46 +0000
Subject: [PATCH] Build fullshtack - plain-language website builder
Create a guided website builder that teaches while you build:
- Landing page with anti-magic pitch explaining the philosophy
- 5-step wizard flow: project basics, data modeling (nouns),
authentication, pages/routing, and styling
- Component library showing functional legos with dependencies
- Dashboard view summarizing the entire project setup
- Plain language throughout - no jargon, just explanations
All explanations translate tech concepts to normal words.
The whole point: understand what you're building before you build it.
---
pages/1_what_are_we_building.py | 151 ++++++++
pages/2_what_are_the_nouns.py | 241 ++++++++++++
pages/3_how_do_people_get_in.py | 208 ++++++++++
pages/4_what_pages_exist.py | 259 +++++++++++++
pages/5_pick_a_vibe.py | 249 ++++++++++++
pages/6_component_library.py | 653 ++++++++++++++++++++++++++++++++
pages/7_your_schtack.py | 290 ++++++++++++++
requirements.txt | 4 +-
streamlit_app.py | 186 +++++++--
utils/__init__.py | 1 +
utils/content.py | 292 ++++++++++++++
utils/state.py | 158 ++++++++
12 files changed, 2662 insertions(+), 30 deletions(-)
create mode 100644 pages/1_what_are_we_building.py
create mode 100644 pages/2_what_are_the_nouns.py
create mode 100644 pages/3_how_do_people_get_in.py
create mode 100644 pages/4_what_pages_exist.py
create mode 100644 pages/5_pick_a_vibe.py
create mode 100644 pages/6_component_library.py
create mode 100644 pages/7_your_schtack.py
create mode 100644 utils/__init__.py
create mode 100644 utils/content.py
create mode 100644 utils/state.py
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": """
+
}
+ renderItem={(item) => (
+ handleNotification(item)}>
+
+
+ {item.title}
+ {item.message}
+
+
+ )}
+/>
+ """,
+ "gotchas": "Handle empty state. Handle loading state. For long lists, use virtualization."
+ },
+ {
+ "name": "Empty State",
+ "description": "what to show when there's nothing to show",
+ "props": ["title", "description", "action"],
+ "dependencies": [],
+ "connects_to": ["Lists", "Tables", "Any data display"],
+ "preview_html": """
+
+
*
+
No products yet
+
Create your first product to get started
+
+
+ """,
+ "code_example": """
+{products.length === 0 ? (
+ setShowCreateModal(true)}>Add product}
+ />
+) : (
+
+)}
+ """,
+ "gotchas": "Every list should have an empty state. It's a teaching moment."
+ }
+ ]
+ },
+ "forms": {
+ "name": "collecting and submitting data",
+ "items": [
+ {
+ "name": "Form",
+ "description": "a container for inputs that collects data and sends it somewhere",
+ "props": ["onSubmit", "children", "validation"],
+ "dependencies": ["API endpoint"],
+ "connects_to": ["API", "Database", "State"],
+ "preview_html": """
+
+ """,
+ "code_example": """
+
+
+
+ Log in
+
+
+
+
+// The onSubmit function should:
+// 1. Validate all fields
+// 2. Send to API
+// 3. Handle success
+// 4. Handle and display errors
+ """,
+ "gotchas": "Always validate server-side too. Show clear error messages. Disable submit while loading."
+ }
+ ]
+ },
+ "feedback": {
+ "name": "telling people what happened",
+ "items": [
+ {
+ "name": "Alert / Toast",
+ "description": "a temporary message that tells people something happened",
+ "props": ["type", "message", "duration", "onClose"],
+ "dependencies": [],
+ "connects_to": ["Any action that needs feedback"],
+ "preview_html": """
+
+
+ + Changes saved successfully
+
+
+ ! Something went wrong. Please try again.
+
+
+ """,
+ "code_example": """
+// After a successful action:
+toast.success("Changes saved successfully")
+
+// After an error:
+toast.error("Something went wrong. Please try again.")
+
+// With more control:
+Upgrade now}
+/>
+ """,
+ "gotchas": "Don't stack too many. Auto-dismiss success, but let errors stick. Be specific about what happened."
+ },
+ {
+ "name": "Loading State",
+ "description": "showing that something is happening, please wait",
+ "props": ["loading", "children"],
+ "dependencies": [],
+ "connects_to": ["Any async operation"],
+ "preview_html": """
+
+
+
Loading your data...
+
+ """,
+ "code_example": """
+{isLoading ? (
+
+) : (
+
+)}
+
+// Or with a wrapper:
+
+
+
+ """,
+ "gotchas": "Always show loading states. Skeleton loaders are better than spinners for layouts."
+ }
+ ]
+ }
+}
+
+# Sidebar - category filter
+with st.sidebar:
+ st.markdown("### categories")
+ selected_category = st.radio(
+ "filter",
+ options=["all"] + list(COMPONENTS.keys()),
+ format_func=lambda x: COMPONENTS[x]["name"] if x != "all" else "all components",
+ label_visibility="collapsed"
+ )
+
+ st.markdown("---")
+
+ st.markdown("### your nouns")
+ if project.nouns:
+ for noun in project.nouns:
+ st.markdown(f"- {noun.name}")
+ st.caption("components can be wired to these")
+ else:
+ st.caption("no nouns defined yet")
+
+ st.markdown("---")
+
+ if st.button(" back to setup"):
+ st.switch_page("pages/1_what_are_we_building.py")
+
+# Main content
+categories_to_show = [selected_category] if selected_category != "all" else COMPONENTS.keys()
+
+for cat_key in categories_to_show:
+ cat_data = COMPONENTS[cat_key]
+ st.markdown(f"### {cat_data['name']}")
+
+ for component in cat_data["items"]:
+ with st.expander(f"**{component['name']}** - {component['description']}"):
+ # Preview
+ st.markdown("#### what it looks like")
+ st.markdown(component["preview_html"], unsafe_allow_html=True)
+
+ col1, col2 = st.columns(2)
+
+ with col1:
+ st.markdown("#### what it needs (props)")
+ for prop in component["props"]:
+ st.markdown(f'{prop}', unsafe_allow_html=True)
+
+ st.markdown("")
+ st.markdown("#### connects to")
+ for conn in component["connects_to"]:
+ st.markdown(f'{conn}', unsafe_allow_html=True)
+
+ with col2:
+ st.markdown("#### dependencies")
+ if component["dependencies"]:
+ for dep in component["dependencies"]:
+ st.markdown(f"- {dep}")
+ else:
+ st.caption("none - standalone component")
+
+ st.markdown("#### watch out for")
+ st.warning(component["gotchas"])
+
+ st.markdown("#### example code")
+ st.code(component["code_example"], language="jsx")
+
+ # Connect to project nouns
+ if project.nouns:
+ st.markdown("#### wire to your data")
+ st.markdown("this component could display or interact with:")
+ cols = st.columns(len(project.nouns))
+ for i, noun in enumerate(project.nouns):
+ with cols[i]:
+ st.checkbox(noun.name, key=f"{cat_key}_{component['name']}_{noun.name}")
+
+ st.markdown("---")
diff --git a/pages/7_your_schtack.py b/pages/7_your_schtack.py
new file mode 100644
index 000000000000..1dfd8e20502c
--- /dev/null
+++ b/pages/7_your_schtack.py
@@ -0,0 +1,290 @@
+"""
+Your Schtack - The Dashboard
+Everything you've set up, in one place. Ready to generate.
+"""
+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, get_progress_summary, export_project_config, reset_project
+from utils.content import SUCCESS_MESSAGES
+
+st.set_page_config(
+ page_title="your schtack | fullshtack",
+ page_icon="",
+ layout="wide"
+)
+
+# Initialize
+init_state()
+project = get_project()
+progress = get_progress_summary()
+
+# Header
+st.markdown(f"## your schtack: {project.project_name or 'unnamed project'}")
+st.markdown("*everything you've set up, in one place*")
+
+if progress["is_complete"]:
+ st.success(SUCCESS_MESSAGES["all_done"])
+else:
+ st.warning("your schtack isn't complete yet. finish the setup to generate code.")
+
+st.markdown("---")
+
+# Overview columns
+col1, col2, col3 = st.columns(3)
+
+with col1:
+ st.markdown("### the basics")
+ st.markdown(f"**project:** {project.project_name or 'not set'}")
+ st.markdown(f"**type:** {project.project_type or 'not set'}")
+ st.markdown(f"**framework:** {project.framework}")
+ st.markdown(f"**database:** {project.database}")
+
+ if project.project_description:
+ st.markdown("---")
+ st.markdown("**what it does:**")
+ st.markdown(f"*{project.project_description}*")
+
+with col2:
+ st.markdown("### your data model")
+ if project.nouns:
+ for noun in project.nouns:
+ with st.expander(f"**{noun.name}**"):
+ if noun.description:
+ st.caption(noun.description)
+ if noun.fields:
+ for field in noun.fields:
+ st.markdown(f"- `{field['name']}` ({field['type']})")
+ else:
+ st.caption("no fields defined")
+ else:
+ st.caption("no nouns defined yet")
+
+with col3:
+ st.markdown("### authentication")
+ if project.auth_method:
+ st.markdown(f"**method:** {project.auth_method}")
+ if project.auth_features:
+ st.markdown("**features:**")
+ for feature in project.auth_features:
+ feature_labels = {
+ "password_reset": "password reset",
+ "remember_me": "remember me",
+ "email_verify": "email verification",
+ "magic_link": "magic link login",
+ "two_factor": "two-factor auth"
+ }
+ st.markdown(f"- {feature_labels.get(feature, feature)}")
+ else:
+ st.caption("not configured yet")
+
+st.markdown("---")
+
+# Pages section
+st.markdown("### your pages")
+
+if project.pages:
+ page_cols = st.columns(min(4, len(project.pages)))
+ for i, page in enumerate(project.pages):
+ with page_cols[i % 4]:
+ auth_icon = "" if page.requires_auth else ""
+ st.markdown(f"""
+
+
{auth_icon} {page.name}
+
{page.path}
+
{page.description or 'no description'}
+
+ """, unsafe_allow_html=True)
+ if page.connected_nouns:
+ st.caption(f"uses: {', '.join(page.connected_nouns)}")
+else:
+ st.caption("no pages defined yet")
+
+st.markdown("---")
+
+# Styling section
+st.markdown("### the vibe")
+
+if project.style_vibe:
+ vibe_descriptions = {
+ "clean": "clean & corporate - professional, lots of white space",
+ "friendly": "friendly & rounded - warm, approachable",
+ "minimal": "minimal & sharp - stark, modern",
+ "brutalist": "brutalist & weird - intentionally raw"
+ }
+
+ col1, col2 = st.columns([2, 1])
+ with col1:
+ st.markdown(f"**style:** {vibe_descriptions.get(project.style_vibe, project.style_vibe)}")
+ st.markdown(f"**primary color:** {project.primary_color}")
+
+ with col2:
+ st.markdown(f"""
+
+ """, unsafe_allow_html=True)
+else:
+ st.caption("no style selected yet")
+
+st.markdown("---")
+
+# Architecture diagram
+st.markdown("### how it all connects")
+st.markdown("""
+Here's the big picture of what you're building:
+""")
+
+# Simple text-based architecture diagram
+architecture = f"""
+```
+ User
+ |
+ v
+ [ {project.framework.upper()} Frontend ]
+ |
+ | (API calls)
+ v
+ [ {project.database.upper()} ]
+ |
+ +-- {', '.join([n.name for n in project.nouns]) or 'your data'}
+ |
+ +-- User accounts {'('+project.auth_method+')' if project.auth_method else ''}
+
+Pages: {' -> '.join([p.path for p in project.pages[:5]]) or '/'}
+```
+"""
+st.markdown(architecture)
+
+st.markdown("---")
+
+# Export section
+st.markdown("### export your config")
+st.markdown("this is the structured definition of your project. it's what we'd use to generate code.")
+
+config = export_project_config()
+
+with st.expander("view full config (JSON)"):
+ st.code(config, language="json")
+
+col1, col2 = st.columns(2)
+
+with col1:
+ st.download_button(
+ label="download config.json",
+ data=config,
+ file_name=f"{project.project_name.lower().replace(' ', '-') if project.project_name else 'project'}-config.json",
+ mime="application/json"
+ )
+
+with col2:
+ if st.button("copy to clipboard"):
+ st.code(config, language="json")
+ st.info("select and copy the config above")
+
+st.markdown("---")
+
+# What's next section
+st.markdown("### what's next")
+
+if not progress["is_complete"]:
+ st.markdown("**finish your setup:**")
+
+ if not progress["has_name"]:
+ if st.button("1. define your project"):
+ st.switch_page("pages/1_what_are_we_building.py")
+ elif not progress["has_nouns"]:
+ if st.button("2. add your data model (nouns)"):
+ st.switch_page("pages/2_what_are_the_nouns.py")
+ elif not progress["has_auth"]:
+ if st.button("3. configure authentication"):
+ st.switch_page("pages/3_how_do_people_get_in.py")
+ elif not progress["has_pages"]:
+ if st.button("4. define your pages"):
+ st.switch_page("pages/4_what_pages_exist.py")
+ elif not progress["has_style"]:
+ if st.button("5. pick your style"):
+ st.switch_page("pages/5_pick_a_vibe.py")
+else:
+ st.markdown("""
+ **your schtack is complete.**
+
+ you now have:
+ - a clear project definition
+ - a data model you understand
+ - authentication configured
+ - pages mapped out
+ - a consistent style
+
+ **what you can do now:**
+
+ 1. **export the config** and use it as a reference while building
+ 2. **browse the component library** to see what pieces you'll need
+ 3. **start coding** - you know exactly what you're building
+
+ the point was never to generate code for you.
+ the point was to make sure you understand what you're building
+ before you build it.
+
+ now go build the thing.
+ """)
+
+ col1, col2, col3 = st.columns(3)
+
+ with col1:
+ if st.button("browse components"):
+ st.switch_page("pages/6_component_library.py")
+
+ with col2:
+ if st.button("edit setup"):
+ st.switch_page("pages/1_what_are_we_building.py")
+
+ with col3:
+ if st.button("start a new project", type="secondary"):
+ reset_project()
+ st.rerun()
+
+st.markdown("---")
+
+# Plain language summary
+st.markdown("### in plain words")
+
+summary_parts = []
+
+if project.project_name and project.project_description:
+ summary_parts.append(f"You're building **{project.project_name}** - {project.project_description}")
+
+if project.nouns:
+ noun_names = [n.name.lower() for n in project.nouns]
+ if len(noun_names) == 1:
+ summary_parts.append(f"It keeps track of **{noun_names[0]}**.")
+ else:
+ summary_parts.append(f"It keeps track of **{', '.join(noun_names[:-1])}** and **{noun_names[-1]}**.")
+
+if project.auth_method:
+ if project.auth_method == "none":
+ summary_parts.append("Anyone can use it without logging in.")
+ elif project.auth_method == "email":
+ summary_parts.append("People log in with their email and password.")
+ elif project.auth_method == "google":
+ summary_parts.append("People log in with their Google account.")
+ elif project.auth_method == "both":
+ summary_parts.append("People can log in with email/password or Google.")
+
+if project.pages:
+ public_pages = [p for p in project.pages if not p.requires_auth]
+ private_pages = [p for p in project.pages if p.requires_auth]
+
+ if public_pages:
+ summary_parts.append(f"There are {len(public_pages)} public page(s) anyone can see.")
+ if private_pages:
+ summary_parts.append(f"There are {len(private_pages)} private page(s) that require login.")
+
+if project.framework and project.database:
+ summary_parts.append(f"It's built with **{project.framework}** and uses **{project.database}** for the database.")
+
+if summary_parts:
+ st.markdown("\n\n".join(summary_parts))
+else:
+ st.caption("complete your setup to see the summary")
diff --git a/requirements.txt b/requirements.txt
index 502d7d1a0d19..77e288293044 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1 @@
-altair
-pandas
-streamlit
+streamlit>=1.28.0
diff --git a/streamlit_app.py b/streamlit_app.py
index ac305b93bffd..5d06a1ebd494 100644
--- a/streamlit_app.py
+++ b/streamlit_app.py
@@ -1,38 +1,170 @@
-from collections import namedtuple
-import altair as alt
-import math
-import pandas as pd
-import streamlit as st
+"""
+fullshtack - the whole thing. no mystery meat.
+A website builder that actually teaches you what you're building.
"""
-# Welcome to Streamlit!
+import streamlit as st
+import sys
+from pathlib import Path
-Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:
+# Add utils to path
+sys.path.insert(0, str(Path(__file__).parent))
-If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
-forums](https://discuss.streamlit.io).
+from utils.state import init_state, get_project, reset_project, get_progress_summary
+from utils.content import HERO_TITLE, HERO_SUBTITLE, LANDING_PITCH
-In the meantime, below is an example of what you can do with just a few lines of code:
-"""
+# Page config
+st.set_page_config(
+ page_title="fullshtack",
+ page_icon="",
+ layout="wide",
+ initial_sidebar_state="collapsed"
+)
+
+# Custom CSS for the vibe
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+# Initialize state
+init_state()
+project = get_project()
+progress = get_progress_summary()
+
+
+def show_landing_page():
+ """The main pitch - what this is and why it's different."""
+
+ # Header
+ st.markdown(f'
{HERO_TITLE}
', unsafe_allow_html=True)
+ st.markdown(f'
{HERO_SUBTITLE}
', unsafe_allow_html=True)
+
+ # The pitch
+ st.markdown(LANDING_PITCH)
+
+ st.markdown("---")
+
+ # CTA
+ col1, col2, col3 = st.columns([1, 2, 1])
+ with col2:
+ if st.button("alright, let's build something", type="primary", use_container_width=True):
+ st.session_state.wizard_started = True
+ st.rerun()
+
+ st.markdown("")
+
+ if progress["has_name"]:
+ st.markdown("---")
+ st.markdown("#### or pick up where you left off")
+ if st.button(f"continue with: {project.project_name}", use_container_width=True):
+ st.switch_page("pages/1_what_are_we_building.py")
+
+
+def show_progress_sidebar():
+ """Show what's been set up in the sidebar."""
+ with st.sidebar:
+ st.markdown("### your schtack")
+
+ if not progress["has_name"]:
+ st.markdown("*nothing yet - let's start*")
+ return
+
+ st.markdown(f"**{project.project_name}**")
+
+ if project.project_description:
+ st.markdown(f"*{project.project_description[:100]}{'...' if len(project.project_description) > 100 else ''}*")
+
+ st.markdown("---")
+
+ # Progress checklist
+ steps = [
+ ("project basics", progress["has_name"], "1_what_are_we_building"),
+ ("data model (nouns)", progress["has_nouns"], "2_what_are_the_nouns"),
+ ("authentication", progress["has_auth"], "3_how_do_people_get_in"),
+ ("pages & routing", progress["has_pages"], "4_what_pages_exist"),
+ ("styling & vibe", progress["has_style"], "5_pick_a_vibe"),
+ ]
+
+ for step_name, is_done, page_name in steps:
+ status = "done" if is_done else "..."
+ icon = "" if is_done else ""
+ if st.button(f"{icon} {step_name}", key=f"nav_{page_name}", use_container_width=True):
+ st.switch_page(f"pages/{page_name}.py")
+
+ st.markdown("---")
+ # Quick links
+ if progress["has_nouns"]:
+ if st.button("component library", use_container_width=True):
+ st.switch_page("pages/6_component_library.py")
-with st.echo(code_location='below'):
- total_points = st.slider("Number of points in spiral", 1, 5000, 2000)
- num_turns = st.slider("Number of turns in spiral", 1, 100, 9)
+ if progress["is_complete"]:
+ if st.button("your schtack (dashboard)", use_container_width=True):
+ st.switch_page("pages/7_your_schtack.py")
- Point = namedtuple('Point', 'x y')
- data = []
+ st.markdown("---")
- points_per_turn = total_points / num_turns
+ if st.button("start over", type="secondary"):
+ reset_project()
+ st.rerun()
- for curr_point_num in range(total_points):
- curr_turn, i = divmod(curr_point_num, points_per_turn)
- angle = (curr_turn + 1) * 2 * math.pi * i / points_per_turn
- radius = curr_point_num / total_points
- x = radius * math.cos(angle)
- y = radius * math.sin(angle)
- data.append(Point(x, y))
- st.altair_chart(alt.Chart(pd.DataFrame(data), height=500, width=500)
- .mark_circle(color='#0068c9', opacity=0.5)
- .encode(x='x:Q', y='y:Q'))
+# Main app logic
+if st.session_state.get('wizard_started') or progress["has_name"]:
+ show_progress_sidebar()
+ if st.session_state.get('wizard_started') and not progress["has_name"]:
+ st.switch_page("pages/1_what_are_we_building.py")
+ else:
+ show_landing_page()
+else:
+ show_landing_page()
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 000000000000..bf9c2910588a
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1 @@
+# fullshtack utilities
diff --git a/utils/content.py b/utils/content.py
new file mode 100644
index 000000000000..56159d4ba0b6
--- /dev/null
+++ b/utils/content.py
@@ -0,0 +1,292 @@
+"""
+Plain language content for fullshtack.
+No jargon. No mystery. Just explanations that make sense.
+"""
+
+# The main pitch
+HERO_TITLE = "fullshtack"
+HERO_SUBTITLE = "the whole thing. no mystery meat."
+
+LANDING_PITCH = """
+**Look.**
+
+Those AI website builders are neat. Type a wish, get a website. Magic.
+
+But here's what they don't tell you:
+
+The moment you need to change something *real* - not the color, not the font,
+the actual thing it *does* - you're standing in a codebase you've never seen before,
+built by an AI that already forgot why it made the choices it made.
+
+You didn't build it. You ordered it. And now you're stuck reverse-engineering
+your own project.
+
+---
+
+**This is different.**
+
+We're not going to generate a website for you while you watch.
+
+We're going to build it *with* you. Step by step. In plain English.
+
+You'll know where everything is because you put it there.
+You'll understand why it works because we explained it like humans,
+not like a CS textbook trying to impress other CS textbooks.
+
+Is it slower than typing "make me an Airbnb clone"?
+Yeah.
+
+Will you actually know what the hell you have at the end?
+Also yeah.
+
+---
+
+**This is for people who want to learn the shit by doing the shit.**
+
+Not by watching an AI do the shit and hoping you absorb it through vibes.
+"""
+
+# Step explanations - what we're actually doing at each stage
+STEP_EXPLANATIONS = {
+ "project": {
+ "title": "what are we building?",
+ "subtitle": "let's start with the basics, in normal words",
+ "explanation": """
+Before we write a single line of code, let's just talk about what you're making.
+
+Not "define your project requirements" - just... what is it?
+A tool for tracking something? A site where people can do a thing?
+An app that connects people who have X with people who need X?
+
+Tell me like you'd tell a friend who asked "so what are you working on?"
+ """,
+ "why_this_matters": """
+Here's why we do this first: every decision that comes after flows from this.
+
+If you're building a blog, you need posts and maybe comments.
+If you're building a marketplace, you need sellers, buyers, and listings.
+If you're building a tool, you need users and whatever they're tracking.
+
+The clearer you are now, the less "wait, I need to redo everything" later.
+ """
+ },
+
+ "nouns": {
+ "title": "what are the nouns?",
+ "subtitle": "the things your app keeps track of",
+ "explanation": """
+Every app is basically: people doing things with stuff.
+
+Let's figure out what "stuff" your app has.
+
+- Users (obviously, almost always)
+- But what else? Products? Posts? Messages? Properties? Tasks?
+
+Just list the nouns. The things. We'll figure out how they connect in a sec.
+
+*Tech translation: this is "data modeling" or "defining your schema" -
+but those words don't help you think clearly about what you actually need.*
+ """,
+ "why_this_matters": """
+Your database is just a filing cabinet for these nouns.
+
+Each noun becomes a "table" - a spreadsheet, basically.
+Each thing you track about that noun becomes a "column" in the spreadsheet.
+
+A User might have: email, name, password (encrypted), when they signed up.
+A Post might have: title, content, who wrote it, when.
+
+This is the skeleton of your app. Everything else hangs off of it.
+ """
+ },
+
+ "auth": {
+ "title": "how do people get in?",
+ "subtitle": "the login situation",
+ "explanation": """
+Does your app need user accounts?
+
+If yes: how should people log in?
+
+- **Just email & password** - the classic
+- **Google/social login** - one click, uses their existing account
+- **Both** - let them choose
+- **No login needed** - it's a public tool, no accounts required
+
+*Tech translation: this is "authentication" - but that word makes it sound
+more complicated than "checking if someone is who they say they are."*
+ """,
+ "why_this_matters": """
+Auth touches everything. It's not just the login page.
+
+It's: who can see what? Who can edit what? What happens when you're not logged in?
+
+We set this up properly now so you're not hacking it in later and creating
+security holes because you're tired and it's 2am.
+ """
+ },
+
+ "pages": {
+ "title": "what pages exist?",
+ "subtitle": "the screens people actually see",
+ "explanation": """
+Let's map out your app like a house.
+
+What rooms are there? Front door (home page), obviously. Then what?
+
+- A page where people sign up?
+- A page where they see their stuff?
+- A page where they create new stuff?
+- A settings page?
+
+Just list them. We'll worry about what's on each page in a minute.
+
+*Tech translation: this is "routing" - but it's really just
+"what URLs exist and what do you see when you go there."*
+ """,
+ "why_this_matters": """
+Your pages are the skeleton of the user experience.
+
+Each page usually does one main thing:
+- Show a list of things
+- Show one thing in detail
+- Let you create/edit a thing
+- Let you configure settings
+
+Knowing your pages means knowing what components you'll need.
+And which nouns show up where.
+ """
+ },
+
+ "styling": {
+ "title": "pick a vibe",
+ "subtitle": "how should this thing look?",
+ "explanation": """
+We're not going to pretend CSS isn't annoying. It is.
+
+But picking a general direction now saves hours of "why doesn't this look right" later.
+
+Pick a vibe:
+
+- **Clean & Corporate** - Lots of white space, professional, trustworthy
+- **Friendly & Rounded** - Softer edges, warmer colors, approachable
+- **Minimal & Sharp** - Less is more, stark contrasts, modern
+- **Brutalist & Weird** - Intentionally raw, anti-design design
+
+*Tech translation: this determines your CSS framework, spacing scale,
+border radius defaults, and color palette - but you don't need to know that.*
+ """,
+ "why_this_matters": """
+A consistent visual language makes your app feel "finished" even when it's not.
+
+We'll set up design tokens (fancy word for "the handful of colors,
+fonts, and sizes you'll reuse everywhere") so everything matches automatically.
+
+You can always change it later. But having a starting point beats
+staring at an unstyled page wondering where to begin.
+ """
+ }
+}
+
+# Component explanations
+COMPONENT_EXPLANATIONS = {
+ "button": {
+ "what_it_is": "A clickable thing that does something when you click it.",
+ "what_it_needs": "Text to display, and what should happen when clicked.",
+ "common_uses": "Submit forms, trigger actions, navigate somewhere.",
+ "gotchas": "Buttons inside forms behave differently than buttons outside forms."
+ },
+ "form": {
+ "what_it_is": "A container for inputs that collects data and sends it somewhere.",
+ "what_it_needs": "Input fields, a submit button, and somewhere to send the data.",
+ "common_uses": "Sign up, log in, create/edit anything, search.",
+ "gotchas": "Always validate on the server too. Never trust the browser alone."
+ },
+ "input": {
+ "what_it_is": "A box where someone types something.",
+ "what_it_needs": "A label (what to type), a name (how to identify it), and validation (what counts as valid).",
+ "common_uses": "Email, password, names, search queries, any text.",
+ "gotchas": "Use the right type (email, password, number) - it changes the keyboard on mobile."
+ },
+ "card": {
+ "what_it_is": "A contained box that groups related content together.",
+ "what_it_needs": "Content to display. Usually has a consistent structure (image, title, description, action).",
+ "common_uses": "Displaying items in a list/grid - products, posts, users, anything.",
+ "gotchas": "Keep cards in a grid consistent. Same info in the same places."
+ },
+ "nav": {
+ "what_it_is": "The menu that lets people move between pages.",
+ "what_it_needs": "Links to your pages. Usually shows different options for logged-in vs logged-out users.",
+ "common_uses": "Header navigation, sidebar navigation, mobile hamburger menu.",
+ "gotchas": "Should clearly show where you currently are."
+ },
+ "modal": {
+ "what_it_is": "A popup that appears over the page, demanding attention.",
+ "what_it_needs": "Content to display, a way to close it, and a trigger to open it.",
+ "common_uses": "Confirmations ('are you sure?'), quick forms, important alerts.",
+ "gotchas": "Don't overuse. If everything is a modal, nothing is."
+ },
+ "table": {
+ "what_it_is": "Data organized in rows and columns. A spreadsheet view.",
+ "what_it_needs": "Column headers and row data. Often needs sorting and filtering.",
+ "common_uses": "Admin panels, data-heavy views, comparison features.",
+ "gotchas": "Terrible on mobile. Consider card layouts for smaller screens."
+ },
+ "list": {
+ "what_it_is": "Items displayed one after another, vertically.",
+ "what_it_needs": "Items to display. Usually each item is clickable.",
+ "common_uses": "Messages, notifications, simple item displays.",
+ "gotchas": "Long lists need pagination or infinite scroll. Don't load 10,000 items."
+ }
+}
+
+
+# Error messages in plain language
+ERROR_MESSAGES = {
+ "empty_name": "You gotta call it something. What's the working title?",
+ "empty_description": "Even a rough idea helps. What does it do, in a sentence?",
+ "no_nouns": "Every app tracks *something*. What are the things in yours?",
+ "no_pages": "People need somewhere to go. What's the first page they see?",
+ "duplicate_noun": "You already have one of those. Did you mean something different?",
+ "invalid_path": "Page paths should look like /something or /something/else - no spaces, lowercase.",
+}
+
+
+# Success messages
+SUCCESS_MESSAGES = {
+ "project_saved": "got it. we know what we're building now.",
+ "nouns_saved": "nice. your data model makes sense.",
+ "auth_saved": "login situation: handled.",
+ "pages_saved": "your app has a map now.",
+ "style_saved": "looking good. well, it will be.",
+ "all_done": "nice. you have a working app skeleton. you know where everything is. nothing weird hiding in the closet."
+}
+
+
+# AI explanation templates
+def explain_like_human(tech_concept: str) -> str:
+ """Translate tech jargon to normal words."""
+ translations = {
+ "api endpoint": "a spot where your app sends/receives data",
+ "database query": "asking your database for specific information",
+ "authentication": "checking if someone is who they say they are",
+ "authorization": "checking if someone is allowed to do what they're trying to do",
+ "middleware": "code that runs between 'request comes in' and 'response goes out'",
+ "state management": "keeping track of what's happening in your app right now",
+ "component": "a reusable piece of your interface",
+ "props": "the settings/data you pass into a component",
+ "hook": "a way to tap into React's features in a function",
+ "schema": "the structure of your data - what fields exist and what type they are",
+ "migration": "a script that changes your database structure",
+ "orm": "code that lets you talk to your database using your programming language instead of SQL",
+ "rest api": "a standard way to structure your app's data endpoints",
+ "crud": "create, read, update, delete - the four basic things you do with data",
+ "jwt": "a secure token that proves someone is logged in",
+ "session": "remembering who someone is while they're using your app",
+ "environment variables": "secret settings that shouldn't be in your code",
+ "deployment": "putting your app on the internet so people can use it",
+ "ssl/https": "encryption that keeps data safe between the user and your server",
+ "cors": "browser security that controls which sites can talk to your app",
+ }
+
+ return translations.get(tech_concept.lower(), f"'{tech_concept}' - honestly, look this one up, it's context-dependent")
diff --git a/utils/state.py b/utils/state.py
new file mode 100644
index 000000000000..ba611a16ce45
--- /dev/null
+++ b/utils/state.py
@@ -0,0 +1,158 @@
+"""
+Project state management for fullshtack.
+Keeps track of everything the user has set up, in plain terms.
+"""
+import streamlit as st
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+import json
+
+
+@dataclass
+class Noun:
+ """A thing you're keeping track of in your app."""
+ name: str
+ description: str
+ fields: List[Dict[str, str]] = field(default_factory=list)
+ relationships: List[Dict[str, str]] = field(default_factory=list)
+
+
+@dataclass
+class Page:
+ """A page in your app."""
+ name: str
+ path: str
+ description: str
+ requires_auth: bool = False
+ connected_nouns: List[str] = field(default_factory=list)
+
+
+@dataclass
+class ProjectState:
+ """The whole picture of what you're building."""
+ # Step 1: What are we building?
+ project_name: str = ""
+ project_description: str = ""
+ project_type: str = "" # 'app', 'site', 'tool', etc.
+
+ # Step 2: What are the nouns?
+ nouns: List[Noun] = field(default_factory=list)
+
+ # Step 3: How do people get in?
+ auth_method: str = "" # 'email', 'google', 'both', 'none'
+ auth_features: List[str] = field(default_factory=list) # 'password_reset', 'remember_me', etc.
+
+ # Step 4: What pages exist?
+ pages: List[Page] = field(default_factory=list)
+
+ # Step 5: Pick a vibe
+ style_vibe: str = "" # 'clean', 'friendly', 'brutalist', 'minimal'
+ primary_color: str = "#0068c9"
+
+ # Stack choices
+ framework: str = "nextjs" # 'nextjs', 'sveltekit', 'astro'
+ database: str = "supabase" # 'supabase', 'planetscale', 'sqlite'
+
+ # Progress tracking
+ current_step: int = 0
+ completed_steps: List[int] = field(default_factory=list)
+
+
+def init_state():
+ """Set up the project state if it doesn't exist."""
+ if 'project' not in st.session_state:
+ st.session_state.project = ProjectState()
+ if 'wizard_started' not in st.session_state:
+ st.session_state.wizard_started = False
+ return st.session_state.project
+
+
+def get_project() -> ProjectState:
+ """Get the current project state."""
+ return init_state()
+
+
+def update_project(**kwargs):
+ """Update the project with new values."""
+ project = get_project()
+ for key, value in kwargs.items():
+ if hasattr(project, key):
+ setattr(project, key, value)
+
+
+def mark_step_complete(step: int):
+ """Mark a wizard step as done."""
+ project = get_project()
+ if step not in project.completed_steps:
+ project.completed_steps.append(step)
+ project.current_step = step + 1
+
+
+def reset_project():
+ """Start over from scratch."""
+ st.session_state.project = ProjectState()
+ st.session_state.wizard_started = False
+
+
+def get_progress_summary() -> Dict:
+ """Get a plain-language summary of what's been set up."""
+ project = get_project()
+
+ summary = {
+ "has_name": bool(project.project_name),
+ "has_nouns": len(project.nouns) > 0,
+ "has_auth": bool(project.auth_method),
+ "has_pages": len(project.pages) > 0,
+ "has_style": bool(project.style_vibe),
+ "is_complete": len(project.completed_steps) >= 5
+ }
+
+ return summary
+
+
+def export_project_config() -> str:
+ """Export the project setup as a config that could generate code."""
+ project = get_project()
+
+ config = {
+ "project": {
+ "name": project.project_name,
+ "description": project.project_description,
+ "type": project.project_type
+ },
+ "stack": {
+ "framework": project.framework,
+ "database": project.database
+ },
+ "data_model": {
+ "entities": [
+ {
+ "name": noun.name,
+ "description": noun.description,
+ "fields": noun.fields,
+ "relationships": noun.relationships
+ }
+ for noun in project.nouns
+ ]
+ },
+ "auth": {
+ "method": project.auth_method,
+ "features": project.auth_features
+ },
+ "pages": [
+ {
+ "name": page.name,
+ "path": page.path,
+ "description": page.description,
+ "requires_auth": page.requires_auth,
+ "uses_data": page.connected_nouns
+ }
+ for page in project.pages
+ ],
+ "styling": {
+ "vibe": project.style_vibe,
+ "primary_color": project.primary_color
+ }
+ }
+
+ return json.dumps(config, indent=2)